Compare commits

..

8 Commits

Author SHA1 Message Date
lebaudantoine 0ee050ab37 wip use a transit token 2026-04-02 17:43:25 +02:00
lebaudantoine f6ec01f3c8 wip rely on cookie for the session service 2026-03-30 23:24:16 +02:00
lebaudantoine 3f77a9d884 wip enforce csp 2026-03-30 16:29:56 +02:00
lebaudantoine c6de0041da wip move the addin in a dedicated babel project 2026-03-16 22:49:47 +01:00
lebaudantoine 0c98dee3ef wip introduce a wip outlook add-in
not production ready
2026-03-16 19:11:06 +01:00
lebaudantoine 768b983f8d fixup! wip introduce add-in endpoints and views 2026-03-16 15:27:51 +01:00
lebaudantoine 3a63b1e410 fixup! wip introduce add-in endpoints and views 2026-02-24 21:30:06 +01:00
lebaudantoine 65b194d432 wip introduce add-in endpoints and views 2026-02-24 21:15:21 +01:00
691 changed files with 14608 additions and 45597 deletions
+6 -8
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__ **/__pycache__
**/*.pyc **/*.pyc
venv venv
**/.venv .venv
# System-specific files # System-specific files
.DS_Store .DS_Store
@@ -24,15 +24,13 @@ data
.cache .cache
.circleci .circleci
.git .git
.vscode
.iml .iml
.idea
db.sqlite3 db.sqlite3
.mypy_cache
.pylint.d .pylint.d
.pytest_cache
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Frontend # Frontend
**/node_modules node_modules
+15 -37
View File
@@ -12,28 +12,20 @@ on:
branches: branches:
- 'main' - 'main'
permissions:
contents: read
env: env:
DOCKER_USER: 1001:127 DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
jobs: jobs:
build-and-push-backend: build-and-push-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -46,7 +38,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -63,23 +55,20 @@ jobs:
with: with:
context: . context: .
target: backend-production target: backend-production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-generic: build-and-push-frontend-generic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -92,7 +81,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -110,23 +99,20 @@ jobs:
context: . context: .
file: ./src/frontend/Dockerfile file: ./src/frontend/Dockerfile
target: frontend-production target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-dinum: build-and-push-frontend-dinum:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -139,7 +125,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -157,23 +143,20 @@ jobs:
context: . context: .
file: ./docker/dinum-frontend/Dockerfile file: ./docker/dinum-frontend/Dockerfile
target: frontend-production target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary: build-and-push-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -186,7 +169,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -206,23 +189,20 @@ jobs:
context: ./src/summary context: ./src/summary
file: ./src/summary/Dockerfile file: ./src/summary/Dockerfile
target: production target: production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents: build-and-push-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -235,7 +215,7 @@ jobs:
images: lasuite/meet-agents images: lasuite/meet-agents
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -255,15 +235,13 @@ jobs:
context: ./src/agents context: ./src/agents
file: ./src/agents/Dockerfile file: ./src/agents/Dockerfile
target: production target: production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
notify-argocd: notify-argocd:
permissions:
contents: read
needs: needs:
- build-and-push-frontend-generic - build-and-push-frontend-generic
- build-and-push-frontend-dinum - build-and-push-frontend-dinum
+20 -65
View File
@@ -82,7 +82,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "22" node-version: "18"
- name: Restore the mail templates - name: Restore the mail templates
uses: actions/cache@v5 uses: actions/cache@v5
@@ -124,17 +124,15 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7 - name: Install development dependencies
- name: Install the project run: pip install --user .[dev]
run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run ruff format . --diff run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run ruff check . run: ~/.local/bin/ruff check .
- name: Lint code with pylint - name: Lint code with pylint
run: uv run pylint meet demo core run: ~/.local/bin/pylint meet demo core
lint-agents: lint-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -150,14 +148,13 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7 - name: Install development dependencies
- name: Install the project run: pip install --user .[dev]
run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run ruff format . --diff run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run ruff check . run: ~/.local/bin/ruff check .
lint-summary: lint-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -224,6 +221,8 @@ jobs:
DB_PORT: 5432 DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1 REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000 AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
@@ -231,7 +230,6 @@ jobs:
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com OIDC_OP_URL: https://oidc.example.com
MEDIA_BASE_URL: http://localhost:8083
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -280,10 +278,10 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7
- name: Install the dependencies - name: Install development dependencies
run: uv sync --locked --all-extras run: pip install --user .[dev]
- name: Install gettext (required to compile messages) - name: Install gettext (required to compile messages)
run: | run: |
@@ -291,53 +289,10 @@ jobs:
sudo apt-get install -y gettext sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project - name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages run: python manage.py compilemessages
- name: Run tests - name: Run tests
run: uv run pytest -n 2 run: ~/.local/bin/pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front: lint-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-29
View File
@@ -1,29 +0,0 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
-4
View File
@@ -31,7 +31,6 @@ MANIFEST
# Translations # Translations # Translations # Translations
*.pot *.pot
*.mo
# Environments # Environments
.env .env
@@ -83,6 +82,3 @@ docker/livekit/out
# LiveKit CA configuration # LiveKit CA configuration
docker/livekit/rootCA.pem docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
+1 -383
View File
@@ -10,381 +10,7 @@ and this project adheres to
### Added ### Added
- ✨(summary) report exception type in failure analytics
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
- ⚡️(frontend) limit unnecessary re-renders #1510
## [1.24.0] - 2026-07-21
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
- ✨(all) allow forcing SSO display name for authenticated users
- (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
- ✨(addon) show add-in tools when creating meetings in shared calendars
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
- 🚸(frontend) initialize the join input name with the persisted full name
- ♻️(frontend) refactor background processors to use the new API
- ♻️(frontend) inline model weights to avoid loading them from remote
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
- ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling
## [1.23.0] - 2026-07-08
### Added
- ✨(backend) extend analytics module to support feature flags
- ✨(backend) implement feature flags in Posthog analytics backend
- ✨(agents) report errors to Sentry for all LiveKit agents
### Changed
- ⬆️(agents) upgrade to python 3.14 slim
- ⬆️(dependencies) update python dependencies
- 💥(summary) remove v1 related code #1362
- ✨(meet) use compatible with summary v2 #1362
- ♻️(backend) refactor analytics backend from Protocol to abstract class
- 🔥(summary) remove call to summary enabled feature flag
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
- ⬆️(frontend) update the frontend build image to Node 22
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
- ✨(summary) more precise analytics events
### Fixed
- 🚀(front) fix frontend build failure
- 🐛(makefile) fix args in make test
- 🩹(backend) fix case-insensitive email deduplication in merge command
- 🐛(summary) support media files with bad streams #1478
## [1.22.0] - 2026-07-03
### Added
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
- 📝(docs) document rebranding the favicon via a volume mount #1443
- ✨(backend) add command to clean pending and deleted files
- 🧱(helm) run clean files command as cronjob
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
### Changed
- ✨(summary) generalized stt api call #1420
- ♻️(env) refactor env variables handling
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
### Fixed
- 🛂(backend) reject user access tokens on the API
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
## [1.21.0] - 2026-06-15
### Added
- ✨(frontend) allow disabling silent login via a URL parameter
- ✨(frontend) allow hiding the login button via a URL parameter
- ✨(summary) add optional satisfaction survey footer
### Changed
- ✨(frontend) enhance noise reduction with BBBA audio processing pipeline
- 🚸(frontend) mute join notification sound in larger rooms
- 🚸(frontend) mute participants by default when joining a large meeting
### Fixed
- 🐛(frontend) fix metadata agent collector enabled check
### Fixed
- ♿️(frontend) improve accessibilty of the Effects panel #1401
## [1.20.0] - 2026-06-12
### Changed
- ♻️(addon) improve Outlook add-on: i18n support, feedback link, smarter link
- ⬆️(frontend) upgrade react-i18next from 15.1.1 to 17.0.8
### Fixed
- 🐛(frontend) fix noise reduction left-channel-only audio
## [1.19.0] - 2026-06-04
### Added
- ✨(backend) add file specific admin #1387
### Changed
- 🐛(agents) fix bug when closing metadata-collector
- ⬆️(dependencies) update python dependencies
- ⬆️(frontend) update js dependencies
- ♻️(agents) replace deprecated room options API
### Fixed
- 🔇(summary) make ffmpeg quiet #1404
- 🔒️(backend) prevent accessing files if they are not ready #1395
- # ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
## [1.18.0] - 2026-06-03
### Added
- 🔧(backend) backport logging configuration from docs
- 🧑‍💻(backend) add management command to merge duplicate users
- 👷(helm) add Kubernetes job for duplicate user merge command
### Fixed
- 🐛(backend) prevent duplicate pending users on concurrent requests
- 🔒️(backend) prevent file change post checks #1377
## [1.17.0] - 2026-05-31
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
- ✨(backend) add core.recording.event.parsers.S3Parser
- ✨(summary) extended support for all video / audio files #1358
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
- 🚀 (paas) remove buildpack requirements.txt to use the new uv.lock #1349
- ✨(backend) allow room configuration and access level via external api #1260
- ♻️(backend) prefix Swagger routes with /api
### Fixed
- 🩹(backend) fix swagger and redoc documentation URLs
## [1.16.0] - 2026-05-13
### Added
- 🔒️(backend) add validation of Room.configuration
- ✨(helm) add support multiple transcribe worker / endpoint #1247
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
- ✨(summary) add speaker-to-participant assignment
### Changed
- ♻️(summary) change tasks endpoint signature
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
- 🧑‍💻(agents) use `uv` for package management
- ✨(summary) improve speaker-to-participant assignment
### Fixed
- ♻(frontend) standardize role terminology across localizations
- 🐛(backend) make start-recording atomic and fault-tolerant
- 🔒️(frontend) room ids are generated with non-cryptographic rand
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
- 🐛(summary) complete webm support #1328
- 🐛(backend) add link to "Open" text in recording email
- 🩹(frontend) fix spacing regression in mobile control bar
## [1.15.0] - 2026-04-30
### Added
- ✨(backend) add metadata collection of VAD, connection and chat events
- ✨(backend) introduce add-ons authentication backend
- 💬(backend) clarify french transcription audio download link text #1299
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
- 🔧(backend) add setting to toggle application token exchange mechanism
- ✨(backend) support add-ons authentication in external viewset
### Fixed
- 🐛(summary) support webm #1290
- ⬆️(backend) bump django-lasuite to v0.0.26
- 🩹(frontend) use a more standard (quality) rating scale
- 🩹(frontend) fix access control for screen recording feature flag
- 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
## [1.14.0] - 2026-04-16
### Added
- 🔒️(helm) Add pod and container securityContext #1197
- ✨(summary) add routes v2 for async STT and summary tasks #1171
- ✅(backend) add unit tests for JwtTokenService #1232
### Changed
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
- ♻️(backend) use Authorization header for LiveKit token authentication
- 🥅(backend) refine Twirp error handling for participant operations
- ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261
- ♿️(frontend) add customizable accessibility fonts #1270
### Fixed
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
- 🔒(backend) rely on backend to allow participant update their metadata
- 🐛(summary) fix failure webhook notification #1233
- 🐛(summary) relax whisperX payload format #1233
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
## [1.13.0] - 2026-03-31
### Changed
- ⬆️(dependencies) update python dependencies
- ♿️(frontend) add explicit region for call controls #1216
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
### Fixed
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
- 🐛(backend) fix regression in update-participant endpoint #1204
## [1.12.0] - 2026-03-24
### Changed
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
- ♿️(frontend) fix more tools heading hierarchy #1181
- ♿️(fronted) improve button descriptions for More tools actions #1184
- 💄(spinner) enforce spinner height #1183
- 💄(custom-background) add upload indicator with preview #1183
- ♿️(backend) improve logo accessibility in recording email notification #1092
- ♿️(summary) improve accessibility of transcription download link #1187
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
- ⬆️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
- ⬆️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
- ⬆️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
### Fixed
- 🐛(frontend) disable personal custom background while deleting #1183
- 🐛(frontend) auto-select new custom background when not logged in #1183
- 🐛(frontend) fix device selection not applying during conference #1156
## [1.11.0] - 2026-03-19
### Added
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿️(frontend) improve chat toast a11y for screen readers #1109
- ♿️(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
- 📌(agents) unpin OpenSSL and related dependencies #1167
- ♿️(frontend) add caption font and background color customization #1122
### Fixed
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
- ♿(frontend) dynamic tab title when connected to meeting #1060
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
### Added
- ✨(backend) add file upload feature #1030
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds - 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed ### Changed
@@ -395,16 +21,8 @@ and this project adheres to
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009 - 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010 - 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011 - ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012 - ♿️(a11y) fix focus ring on tab container components
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20 ## [1.8.0] - 2026-02-20
+24 -42
View File
@@ -13,38 +13,24 @@ RUN apk update && \
# ---- Back-end builder image ---- # ---- Back-end builder image ----
FROM base AS back-builder FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1 # Copy required python dependencies
ENV UV_LINK_MODE=copy COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter RUN mkdir /install && \
# across both images. If using a managed Python version, it needs to be pip install --prefix=/install .
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ---- # ---- mails ----
FROM node:22 AS mail-builder FROM node:20 AS mail-builder
COPY ./src/mail /mail/app COPY ./src/mail /mail/app
WORKDIR /mail/app WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \ RUN yarn install --frozen-lockfile && \
yarn build yarn build
# ---- static link collector ---- # ---- static link collector ----
@@ -53,20 +39,19 @@ ARG MEET_STATIC_ROOT=/data/static
RUN apk add \ RUN apk add \
pango \ pango \
libmagic \
rdfind rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic # collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the # Replace duplicated file by a symlink to decrease the overall size of the
# final image # final image
@@ -83,7 +68,6 @@ RUN apk --no-cache add \
gettext \ gettext \
libffi-dev \ libffi-dev \
pango \ pango \
libmagic \
shared-mime-info shared-mime-info
@@ -95,17 +79,14 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint). # docker user (see entrypoint).
RUN chmod g=u /etc/passwd RUN chmod g=u /etc/passwd
# Copy the application from the builder # Copy installed python dependencies
COPY --from=back-builder /app /app COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that # We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group # creates a user on-the-fly with the container user ID (see USER) and root group
# ID. # ID.
@@ -120,9 +101,10 @@ USER root:root
# Install psql # Install psql
RUN apk add postgresql-client RUN apk add postgresql-client
# Install development dependencies # Uninstall Meet and re-install it in editable mode along with development
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \ # dependencies
uv sync --all-extras --locked RUN pip uninstall -y meet
RUN pip install -e .[dev]
# Restore the un-privileged user running the application # Restore the un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
@@ -131,7 +113,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services # Target database host (e.g. database engine following docker compose services
# name) & port # name) & port
ENV DB_HOST=postgresql \ ENV DB_HOST=postgresql \
DB_PORT=5432 DB_PORT=5432
# Run django development server # Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+26 -61
View File
@@ -23,10 +23,9 @@
# ============================================================================== # ==============================================================================
# VARIABLES # VARIABLES
ESC := $(shell printf '\033') BOLD := \033[1m
BOLD := $(ESC)[1m RESET := \033[0m
RESET := $(ESC)[0m GREEN := \033[1;32m
GREEN := $(ESC)[1;32m
# -- Database # -- Database
@@ -73,10 +72,7 @@ create-env-files: \
env.d/development/crowdin \ env.d/development/crowdin \
env.d/development/postgresql \ env.d/development/postgresql \
env.d/development/kc_postgresql \ env.d/development/kc_postgresql \
env.d/development/summary \ env.d/development/summary
env.d/development/kube-secret \
env.d/development/multi_user_transcriber \
env.d/development/metadata_collector
.PHONY: create-env-files .PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project bootstrap: ## Prepare Docker images for the project
@@ -89,15 +85,13 @@ bootstrap: \
demo \ demo \
back-i18n-compile \ back-i18n-compile \
mails-install \ mails-install \
mails-build \ mails-build
run
.PHONY: bootstrap .PHONY: bootstrap
# -- Docker/compose # -- Docker/compose
build: ## build the project containers build: ## build the project containers
@$(MAKE) build-backend @$(MAKE) build-backend
@$(MAKE) build-frontend @$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build .PHONY: build
build-backend: ## build the app-dev container build-backend: ## build the app-dev container
@@ -109,10 +103,6 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend @$(COMPOSE) build frontend
.PHONY: build-frontend .PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber-dev
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down @$(COMPOSE) down
.PHONY: down .PHONY: down
@@ -122,8 +112,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs .PHONY: logs
run-backend: ## start only the backend application and all needed services run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans @$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..." @echo "Wait for postgresql to be up..."
@$(WAIT_DB) @$(WAIT_DB)
.PHONY: run-backend .PHONY: run-backend
@@ -133,24 +122,10 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize @$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary .PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent
@$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector
.PHONY: run-agents
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
.PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
.PHONY: run-agent-metadata-collector
run: run:
run: ## start the wsgi (production) and development server run: ## start the wsgi (production) and development server
@$(MAKE) run-backend @$(MAKE) run-backend
@$(MAKE) run-summary @$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend @$(COMPOSE) up --force-recreate -d frontend
.PHONY: run .PHONY: run
@@ -211,27 +186,20 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
@$(COMPOSE_RUN_APP) pylint meet demo core @$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint .PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"` test: ## run project tests
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @$(MAKE) test-back-parallel
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
.PHONY: test .PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS) test-back: ## run back-end tests
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args} bin/pytest $${args:-${1}}
.PHONY: test-back .PHONY: test-back
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS) test-back-parallel: ## run all back-end tests in parallel
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args} bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel .PHONY: test-back-parallel
test-summary: ## run summary tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project. makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)" @echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql @$(COMPOSE) up -d postgresql
@@ -252,7 +220,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser .PHONY: superuser
back-i18n-compile: ## compile the gettext files back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore=".venv/**/*" @$(MANAGE) compilemessages --ignore="venv/**/*"
.PHONY: back-i18n-compile .PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n back-i18n-generate: ## create the .pot files used for i18n
@@ -288,15 +256,6 @@ env.d/development/kc_postgresql:
env.d/development/summary: env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary cp -n env.d/development/summary.dist env.d/development/summary
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
env.d/development/metadata_collector:
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
# -- Internationalization # -- Internationalization
env.d/development/crowdin: env.d/development/crowdin:
@@ -384,15 +343,21 @@ frontend-i18n-generate: \
# -- K8S # -- K8S
build-k8s-cluster: ## build the kubernetes cluster using kind build-k8s-cluster: ## build the kubernetes cluster using kind
build-k8s-cluster: \ ./bin/start-kind.sh
env.d/development/kube-secret \ .PHONY: build-k8s-cluster
./bin/start-kind.sh
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
+13 -59
View File
@@ -12,10 +12,7 @@
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE"> <a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a> </a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
</a>
</p> </p>
<p align="center"> <p align="center">
@@ -31,14 +28,6 @@
## La Suite Meet: Simple Video Conferencing ## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/). Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
> [!TIP]
> New here? Start by introducing yourself in our Matrix channel:
> **https://matrix.to/#/#meet-official:matrix.org**
>
> Were happy to discuss ideas, answer questions, and help to deploy LaSuite Meet.
### Features ### Features
- Optimized for stability in large meetings (+100 p.) - Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams - Support for multiple screen sharing streams
@@ -63,7 +52,7 @@ Were continuously adding new features to enhance your experience, with the la
### 🚀 Major roll out to all French public servants ### 🚀 Major roll out to all French public servants
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html)) On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents ## Table of Contents
@@ -95,57 +84,22 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
#### Known instances #### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏 We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access | | Url | Org | Access |
|---------------------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------| |---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [Clever Cloud](https://www.clever.cloud/product/visio/) | clever cloud | Openvisio is a sovereign video conferencing solution based on LaSuite Meet offered by [Clever Cloud](https://www.clever.cloud/). |
| [Email.eu](https://email.eu/) | Email.eu | Sovereign business workspace. |
# Contributing ## Contributing
We <3 contributions of all kinds **big or small** and were genuinely glad youre here. 🌱 We <3 contributions of any kind, big and small:
### Start by saying hi - Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
**The best first contribution is simply to come say hi.**
Before opening a PR, especially a larger one, or one written with the help of AI, we encourage you to reach out to a maintainer on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org) (@antoine.lebaud:matrix.org).
Getting in touch early helps us align on goals, avoid duplicated or wasted effort, and build a community that stays active, welcoming, and fun to be part of. There are no silly questions here: whether youve shipped hundreds of PRs or youre just getting started, youre welcome.
### AI contributions
AI-assisted contributions are welcome. But code is never the end goal. What matters most is building relationships, sharing knowledge, and growing a sustainable community over time.
If your contribution has been heavily generated with AI, please be transparent about it. This helps maintainers review it with the right context and respects the time they invest in the project.
Using AI does not transfer ownership of the contribution: you should still fully understand the code, the problem it solves, and the reasoning behind the approach you propose. In short, even if AI helped write it, the why should still be yours.
### Contributions beyond code
**Not technical? We need you too.**
Open source is much more than code. Writing documentation, improving onboarding, translating content, answering questions, reporting bugs, or simply helping others feel welcome all make a huge difference.
### Ways to contribute
When youre ready, here are a few ways to get involved:
* 👋 **Say hello** and share your ideas with the community and maintainers on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org)
* 🛠️ **Open a PR** by following our guide to [develop La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)
* 💡 **Suggest an idea** by opening a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md)
* 🐛 **Report a bug** by opening a [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
Thank you for helping build something open, useful, and human. 💙
### Community call
We host a community call on the first Friday of every month to share updates, discuss ideas, and connect with contributors.
Whether youre actively contributing or just curious about the project, youre welcome to join. More details are shared on the [Matrix channel](https://matrix.to/#/#meet-official:matrix.org).
## Philosophy ## Philosophy
-12
View File
@@ -15,15 +15,3 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.) (Note : in your development environment, you can `make migrate`.)
## [Unreleased] ## [Unreleased]
## v1.23.0
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
+11 -28
View File
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject') load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet') namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak') DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum': if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"]) update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
@@ -18,13 +18,14 @@ docker_build(
'localhost:5001/meet-backend:latest', 'localhost:5001/meet-backend:latest',
context='..', context='..',
dockerfile='../Dockerfile', dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'], only=['./src/backend', './src/mail', './docker'],
target = 'backend-production', target = 'backend-production',
live_update=[ live_update=[
sync('../src/backend', '/app'), sync('../src/backend', '/app'),
run( run(
'uv sync --locked --no-dev', 'pip install -r /app/requirements.txt',
trigger=['../src/backend/uv.lock', '../src/backend/pyproject.toml'] trigger=['./api/requirements.txt']
) )
] ]
) )
@@ -33,12 +34,12 @@ clean_old_images('localhost:5001/meet-backend')
docker_build( docker_build(
'localhost:5001/meet-frontend-dinum:latest', 'localhost:5001/meet-frontend-dinum:latest',
context='..', context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile', dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './src/addons', './docker', './.dockerignore'], only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production', target = 'frontend-production',
live_update=[ live_update=[
sync('../src/frontend', '/home/frontend'), sync('../src/frontend', '/home/frontend'),
sync('../src/addons', '/home/addons'),
] ]
) )
clean_old_images('localhost:5001/meet-frontend-dinum') clean_old_images('localhost:5001/meet-frontend-dinum')
@@ -58,6 +59,7 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build( docker_build(
'localhost:5001/meet-summary:latest', 'localhost:5001/meet-summary:latest',
context='../src/summary', context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile', dockerfile='../src/summary/Dockerfile',
only=['.'], only=['.'],
target = 'production', target = 'production',
@@ -70,6 +72,7 @@ clean_old_images('localhost:5001/meet-summary')
docker_build( docker_build(
'localhost:5001/meet-agents:latest', 'localhost:5001/meet-agents:latest',
context='../src/agents', context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile', dockerfile='../src/agents/Dockerfile',
only=['.'], only=['.'],
target = 'production', target = 'production',
@@ -96,34 +99,14 @@ docker_build(
) )
clean_old_images('localhost:5001/meet-livekit') clean_old_images('localhost:5001/meet-livekit')
load('ext://secret', 'secret_yaml_generic') k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_yaml(secret_yaml_generic(
name="secret-dev",
from_env_file="../env.d/development/kube-secret"
))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('minio-bucket', resource_deps=['minio']) k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server']) k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis']) k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server']) k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql']) k8s_resource('keycloak', resource_deps=['kc-postgresql'])
# Trigger once on launch k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
k8s_resource(
'meet-backend-createsuperuser',
resource_deps=['meet-backend-migrate'],
trigger_mode=TRIGGER_MODE_MANUAL,
)
k8s_resource(
'meet-backend-migrate',
resource_deps=['meet-backend'],
trigger_mode=TRIGGER_MODE_MANUAL,
)
migration = ''' migration = '''
set -eu set -eu
+1
View File
@@ -47,3 +47,4 @@ mv src/backend/* ./
mv deploy/paas/* ./ mv deploy/paas/* ./
echo "3.13" > .python-version echo "3.13" > .python-version
echo "." > requirements.txt
-14
View File
@@ -101,24 +101,12 @@ update_npm_version "mail"
# Update backend pyproject.toml # Update backend pyproject.toml
update_python_version "backend" update_python_version "backend"
# Run uv lock in backend
print_info "Running uv lock in backend..."
cd "src/backend"
uv lock
cd -
# Update summary pyproject.toml # Update summary pyproject.toml
update_python_version "summary" update_python_version "summary"
# Update agents pyproject.toml # Update agents pyproject.toml
update_python_version "agents" update_python_version "agents"
# Run uv lock in agents
print_info "Running uv lock in agents..."
cd "src/agents"
uv lock
cd -
# Update CHANGELOG # Update CHANGELOG
print_info "Updating CHANGELOG..." print_info "Updating CHANGELOG..."
@@ -161,10 +149,8 @@ echo " - src/frontend/package.json"
echo " - src/sdk/package.json" echo " - src/sdk/package.json"
echo " - src/mail/package.json" echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml" echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml" echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml" echo " - src/agents/pyproject.toml"
echo " - src/agents/uv.lock"
echo " - CHANGELOG.md" echo " - CHANGELOG.md"
echo "" echo ""
print_warning "Next steps:" print_warning "Next steps:"
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+13 -43
View File
@@ -58,7 +58,7 @@ services:
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' && /usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json && /usr/bin/mc admin service restart meet --wait --json &&
sleep 15 && sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" && /usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;" exit 0;"
app-dev: app-dev:
@@ -80,11 +80,11 @@ services:
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
- /app/.venv
depends_on: depends_on:
- postgresql - postgresql
- mailcatcher - mailcatcher
- redis - redis
- nginx
- livekit - livekit
- createbuckets - createbuckets
- createwebhook - createwebhook
@@ -93,7 +93,7 @@ services:
networks: networks:
- resource-server - resource-server
- default - default
celery-dev: celery-dev:
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
image: meet:backend-development image: meet:backend-development
@@ -106,7 +106,6 @@ services:
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
- /app/.venv
depends_on: depends_on:
- app-dev - app-dev
@@ -149,7 +148,6 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on: depends_on:
- keycloak - keycloak
- app-dev
networks: networks:
- resource-server - resource-server
- default - default
@@ -237,42 +235,14 @@ services:
- livekit-egress - livekit-egress
livekit-egress: livekit-egress:
image: livekit/egress:v1.11.0 image: livekit/egress:v1.11.0
environment: environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes: volumes:
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml - ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
- ./docker/livekit/out:/out - ./docker/livekit/out:/out
depends_on: depends_on:
- redis - redis
metadata-collector-dev:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
env_file:
- env.d/development/metadata_collector
volumes:
- ./src/agents:/app
- /app/.venv
depends_on:
- livekit
- minio
develop:
watch:
- action: rebuild
path: ./src/agents
multi-user-transcriber-dev:
build:
context: ./src/agents
target: development
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
- /app/.venv
redis-summary: redis-summary:
image: redis image: redis
@@ -301,7 +271,7 @@ services:
context: ./src/summary context: ./src/summary
dockerfile: Dockerfile dockerfile: Dockerfile
target: production target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2 command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file: env_file:
- env.d/development/summary - env.d/development/summary
volumes: volumes:
@@ -321,7 +291,7 @@ services:
context: ./src/summary context: ./src/summary
dockerfile: Dockerfile dockerfile: Dockerfile
target: production target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2 command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file: env_file:
- env.d/development/summary - env.d/development/summary
volumes: volumes:
+18 -22
View File
@@ -1,5 +1,5 @@
# ---- Front-end image ---- # ---- Front-end image ----
FROM node:22-alpine AS frontend-deps FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/ WORKDIR /home/frontend/
@@ -38,34 +38,30 @@ COPY ./docker/dinum-frontend/assets/ \
COPY ./docker/dinum-frontend/fonts/ \ COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/ ./dist/assets/fonts/
# ---- Addons builder image ---- # ---- Outlook add-in builder image ----
FROM node:20-alpine AS addons-builder FROM node:20-alpine AS outlook-addin-builder
WORKDIR /home/addons/outlook WORKDIR /home/outlook-addin
COPY ./src/addons/outlook/package.json ./package.json COPY ./src/addins/outlook-addin/package.json ./package.json
COPY ./src/addons/outlook/package-lock.json ./package-lock.json COPY ./src/addins/outlook-addin/package-lock.json ./package-lock.json
RUN npm ci RUN npm ci
COPY ./src/addons/outlook/ . COPY ./src/addins/outlook-addin/ .
RUN npx webpack --mode production RUN npx webpack --mode production
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root USER root
RUN apk update && apk upgrade libssl3 \
# Security patches for known CVEs libcrypto3 \
RUN apk update && apk upgrade \ libxml2>=2.12.7-r2 \
libcrypto3>=3.5.7-r0 \ libxslt>=1.1.39-r2 \
libssl3>=3.5.7-r0 \ libexpat>=2.7.2-r0 \
musl \ libpng>=1.6.53-r0
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
USER nginx USER nginx
@@ -77,11 +73,11 @@ COPY --from=meet-builder \
/home/frontend/dist \ /home/frontend/dist \
/usr/share/nginx/html /usr/share/nginx/html
COPY --from=addons-builder \ COPY --from=outlook-addin-builder \
/home/addons/outlook/dist \ /home/outlook-addin/dist \
/usr/share/nginx/html/addons/outlook /usr/share/nginx/html/outlook-addin
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ] ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
-85
View File
@@ -1,85 +0,0 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
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";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
# 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;
}
@@ -4,47 +4,10 @@ server {
server_name localhost; server_name localhost;
charset utf-8; charset utf-8;
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
add_header Content-Disposition "attachment";
}
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / { location / {
proxy_pass http://keycloak:8080; proxy_pass http://keycloak:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
} }
} }
+4 -3
View File
@@ -61,10 +61,11 @@ services:
`docker compose up -d` `docker compose up -d`
``` ```
Your keycloak instance is now available on https://id.yourdomain.tld Your keycloak instance is now available on https://doc.yourdomain.tld
> [!CAUTION] > [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags). > Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application ## Creating an OIDC Client for Meet Application
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
3. Enter the name of the realm - `meet`. 3. Enter the name of the realm - `meet`.
4. Click "Create". 4. Click "Create".
### Step 2: Create a New Client #### Step 2: Create a New Client
1. Navigate to the "Clients" tab. 1. Navigate to the "Clients" tab.
2. Click on the "Create client" button. 2. Click on the "Create client" button.
@@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`. 1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save". 1. Click "Save".
### Step 3: Get Client Credentials #### Step 3: Get Client Credentials
1. Go to the "Credentials" tab. 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.
+3 -3
View File
@@ -68,13 +68,13 @@ backend:
python manage.py createsuperuser --email admin@example.com --password admin python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never restartPolicy: Never
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts: extraVolumeMounts:
- name: certs - name: certs
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes: extraVolumes:
- name: certs - name: certs
configMap: configMap:
+1 -59
View File
@@ -96,17 +96,10 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. | | **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. | | **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. | | **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. | | **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. | | **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. | | **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. | | **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook ### Manual Storage Webhook
@@ -148,54 +141,3 @@ Using default project meet
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly. This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage.
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+1
View File
@@ -80,6 +80,7 @@ sequenceDiagram
| whisperx_api_key | Secret | — | API key for accessing WhisperX. | | whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. | | whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. | | whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. | | webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. | | webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. | | webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
+2 -2
View File
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`. Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file. We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml) To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE] > [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports. > In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
### Meet ### Meet
+8 -71
View File
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet $ kubectl create namespace meet
``` ```
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed. If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
``` ```
$ kubectl config set-context --current --namespace=meet $ kubectl config set-context --current --namespace=meet
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml $ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until $ #wait until
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email" OIDC_RP_SCOPES: "openid email"
``` ```
You can find these values in **examples/helm/keycloak.values.yaml** You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values ### Find livekit server connexion values
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
Livekit need a redis (and meet too) so we will start by deploying a redis : Livekit need a redis (and meet too) so we will start by deploying a redis :
``` ```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml $ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m keycloak-0 1/1 Running 0 26m
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
``` ```
$ helm repo add livekit https://helm.livekit.io $ helm repo add livekit https://helm.livekit.io
$ helm repo update $ helm repo update
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml $ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 30m keycloak-0 1/1 Running 0 30m
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows: LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
``` ```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml $ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 45m keycloak-0 1/1 Running 0 45m
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
``` ```
$ helm repo add meet https://suitenumerique.github.io/meet/ $ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo update $ helm repo update
$ helm install meet meet/meet -f examples/helm/meet.values.yaml $ helm install meet meet/meet -f examples/meet.values.yaml
``` ```
## Test your deployment ## Test your deployment
@@ -244,69 +244,6 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet. You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## Rebranding the favicon
The favicon is bundled into the frontend image and served as a set of static
files from `/usr/share/nginx/html` (`favicon.ico`, `favicon-16x16.png`,
`favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`,
`android-chrome-512x512.png`, `icon.png`). To rebrand without forking and
rebuilding the image, overlay your own icons onto those paths with a volume —
this serves the right icon from the first byte (no rebuild, no flash) and
covers every variant, including the iOS home-screen and Android/PWA icons.
Put your icons in a `ConfigMap` (`binaryData` keeps the PNGs intact)…
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: meet-favicon
binaryData:
# base64 of each replacement icon
favicon.ico: <base64…>
favicon-16x16.png: <base64…>
favicon-32x32.png: <base64…>
apple-touch-icon.png: <base64…>
android-chrome-192x192.png: <base64…>
android-chrome-512x512.png: <base64…>
```
```bash
# e.g. build the ConfigMap straight from a directory of icons
$ kubectl create configmap meet-favicon --from-file=./my-icons/
```
…then mount each file over the bundled one via the chart's
`frontend.extraVolumes` / `frontend.extraVolumeMounts` (the `subPath` mounts
the single file without hiding the rest of `html/`):
```yaml
frontend:
extraVolumes:
- name: favicon
configMap:
name: meet-favicon
extraVolumeMounts:
- name: favicon
mountPath: /usr/share/nginx/html/favicon.ico
subPath: favicon.ico
- name: favicon
mountPath: /usr/share/nginx/html/favicon-16x16.png
subPath: favicon-16x16.png
- name: favicon
mountPath: /usr/share/nginx/html/favicon-32x32.png
subPath: favicon-32x32.png
- name: favicon
mountPath: /usr/share/nginx/html/apple-touch-icon.png
subPath: apple-touch-icon.png
- name: favicon
mountPath: /usr/share/nginx/html/android-chrome-192x192.png
subPath: android-chrome-192x192.png
- name: favicon
mountPath: /usr/share/nginx/html/android-chrome-512x512.png
subPath: android-chrome-512x512.png
```
## All options ## All options
These are the environmental options available on meet backend. These are the environmental options available on meet backend.
@@ -407,7 +344,7 @@ These are the environmental options available on meet backend.
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} | | RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser | | RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true | | RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false | | RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | | | RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | | | RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | | | RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
+12 -67
View File
@@ -185,13 +185,11 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: {}
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/rooms/:
post: post:
tags: tags:
- Rooms - Rooms
@@ -199,6 +197,10 @@ paths:
description: | description: |
Creates a new room with secure defaults for external API usage. Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:** **Defaults:**
- Delegated user is set as owner - Delegated user is set as owner
- Room slug auto-generated for uniqueness - Room slug auto-generated for uniqueness
@@ -215,17 +217,8 @@ paths:
$ref: '#/components/schemas/RoomCreate' $ref: '#/components/schemas/RoomCreate'
examples: examples:
emptyBody: emptyBody:
summary: No parameters (use all defaults) summary: No parameters (default)
value: { } value: {}
withAccessLevel:
summary: Specify access level
value:
access_level: "trusted"
withConfiguration:
summary: Provide room configuration
value:
configuration:
everyone_can_mute: true
responses: responses:
'201': '201':
description: Room created successfully description: Room created successfully
@@ -275,7 +268,6 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: {}
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
@@ -351,56 +343,8 @@ components:
RoomCreate: RoomCreate:
type: object type: object
description: | description: Empty object - all room properties are auto-generated
Optional fields for room creation. All fields have secure defaults if omitted. properties: {}
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
Optional room behaviour settings. Unknown fields are rejected.
All fields are optional and default to `null` (server-side defaults apply).
properties:
can_publish_sources:
type: array
nullable: true
description: |
Restricts which media tracks participants are allowed to publish.
If `null`, all sources are permitted.
items:
type: string
enum:
- camera
- microphone
- screen_share
- screen_share_audio
example: [ "camera", "microphone" ]
everyone_can_mute:
type: boolean
nullable: true
description: |
Whether any participant can mute others, or only the room owner/moderator.
If `null`, the server default applies.
example: true
additionalProperties: false
RoomAccessLevel:
type: string
enum:
- public
- trusted
- restricted
description: |
Controls who can join the room without going through the lobby.
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
example: "trusted"
Room: Room:
type: object type: object
@@ -417,7 +361,10 @@ components:
description: URL-friendly room identifier (auto-generated) description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer" example: "aze-eere-zer"
access_level: access_level:
$ref: '#/components/schemas/RoomAccessLevel' type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url: url:
type: string type: string
format: uri format: uri
@@ -445,8 +392,6 @@ components:
type: string type: string
description: Default country code description: Default country code
example: "US" example: "US"
configuration:
$ref: '#/components/schemas/RoomConfiguration'
OAuthError: OAuthError:
type: object type: object
+70 -70
View File
@@ -48,7 +48,7 @@ paths:
summary: List rooms summary: List rooms
description: | description: |
Returns a list of rooms accessible to the authenticated user. Returns a list of rooms accessible to the authenticated user.
Only rooms where the user has access will be returned. Only rooms where the delegated user has access will be returned.
operationId: listRooms operationId: listRooms
security: security:
- BearerAuth: [rooms:list] - BearerAuth: [rooms:list]
@@ -108,22 +108,24 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: { }
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/rooms/:
post: post:
tags: tags:
- Rooms - Rooms
summary: Create a room summary: Create a room
description: | description: |
Creates a new room with secure defaults for external API usage. Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:** **Defaults:**
- user is set as owner - Delegated user is set as owner
- Room slug auto-generated for uniqueness - Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled - Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing - Creation tracked with application client_id for auditing
@@ -138,17 +140,8 @@ paths:
$ref: '#/components/schemas/RoomCreate' $ref: '#/components/schemas/RoomCreate'
examples: examples:
emptyBody: emptyBody:
summary: No parameters (use all defaults) summary: No parameters (default)
value: { } value: {}
withAccessLevel:
summary: Specify access level
value:
access_level: "trusted"
withConfiguration:
summary: Provide room configuration
value:
configuration:
everyone_can_mute: true
responses: responses:
'201': '201':
description: Room created successfully description: Room created successfully
@@ -198,7 +191,6 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: { }
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
@@ -217,58 +209,65 @@ components:
Include in requests as: `Authorization: Bearer <token>` Include in requests as: `Authorization: Bearer <token>`
schemas: schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate: RoomCreate:
type: object type: object
description: | description: Empty object - all room properties are auto-generated
Optional fields for room creation. All fields have secure defaults if omitted. properties: {}
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
Optional room behaviour settings. Unknown fields are rejected.
All fields are optional and default to `null` (server-side defaults apply).
properties:
can_publish_sources:
type: array
nullable: true
description: |
Restricts which media tracks participants are allowed to publish.
If `null`, all sources are permitted.
items:
type: string
enum:
- camera
- microphone
- screen_share
- screen_share_audio
example: [ "camera", "microphone" ]
everyone_can_mute:
type: boolean
nullable: true
description: |
Whether any participant can mute others, or only the room owner/moderator.
If `null`, the server default applies.
example: true
additionalProperties: false
RoomAccessLevel:
type: string
enum:
- public
- trusted
- restricted
description: |
Controls who can join the room without going through the lobby.
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
example: "trusted"
Room: Room:
type: object type: object
@@ -285,7 +284,10 @@ components:
description: URL-friendly room identifier (auto-generated) description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer" example: "aze-eere-zer"
access_level: access_level:
$ref: '#/components/schemas/RoomAccessLevel' type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url: url:
type: string type: string
format: uri format: uri
@@ -313,8 +315,6 @@ components:
type: string type: string
description: Default country code description: Default country code
example: "US" example: "US"
configuration:
$ref: '#/components/schemas/RoomConfiguration'
OAuthError: OAuthError:
type: object type: object
+4 -4
View File
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
### How to Use ### How to Use
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example: To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript ```javascript
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css FRONTEND_CSS_URL=https://example.com/custom-style.css
``` ```
> [!TIP] > [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`. > If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding. Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
} }
``` ```
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified. Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT] > [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens. > You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
+1 -25
View File
@@ -23,12 +23,9 @@ MEET_BASE_URL="http://localhost:8072"
# Media # Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC # OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -57,7 +54,6 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
LIVEKIT_API_SECRET=secret LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880 LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_INTERNAL_URL=http://livekit:7880
LIVEKIT_VERIFY_SSL=False LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False ALLOW_UNREGISTERED_ROOMS=False
@@ -65,32 +61,13 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Telephony # Telephony
ROOM_TELEPHONY_ENABLED=True ROOM_TELEPHONY_ENABLED=True
# Metadata
METADATA_COLLECTOR_ENABLED=True
# Subtitle
ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False FRONTEND_USE_PROCONNECT_BUTTON=False
@@ -99,4 +76,3 @@ EXTERNAL_API_ENABLED=True
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000 APPLICATION_BASE_URL=http://localhost:3000
-4
View File
@@ -1,4 +0,0 @@
WHISPERX_BASE_URL=https://configure-your-url.com
WHISPERX_API_KEY=<key>
LLM_BASE_URL=https://configure-your-url.com
LLM_API_KEY=<key>
@@ -1,9 +0,0 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -1,14 +0,0 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=kyutai # kyutai, deepgram
ENABLE_SILERO_VAD=False
DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
+1 -10
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev" APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password" APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage" AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000" AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false AWS_S3_SECURE_ACCESS=false
@@ -20,14 +20,5 @@ LLM_MODEL="albert-large"
WEBHOOK_API_TOKEN="secret" WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com" WEBHOOK_URL="https://configure-your-url.com"
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED=true
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP=0.5
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS=true
RESOLVE_SPEAKER_MAX_WORD_DURATION=1
POSTHOG_API_KEY="your-posthog-key" POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False" POSTHOG_ENABLED="False"
# Transcription
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
-1
View File
@@ -2,7 +2,6 @@
Gitlint extra rule to validate that the message title is of the form Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>" "<gitmoji>(<scope>) <subject>"
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
import re import re
-6
View File
@@ -36,12 +36,6 @@
"matchPackageNames": ["django"], "matchPackageNames": ["django"],
"allowedVersions": "<6.0.0" "allowedVersions": "<6.0.0"
}, },
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+173
View File
@@ -0,0 +1,173 @@
<?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>
@@ -9,36 +9,34 @@
"version": "0.0.1", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "^3.36.0",
"i18next": "^26.3.2", "regenerator-runtime": "^0.14.1"
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "7.29.0", "@babel/preset-env": "^7.25.4",
"@types/office-js": "1.0.582", "@types/office-js": "^1.0.377",
"@types/office-runtime": "1.0.36", "@types/office-runtime": "^1.0.35",
"acorn": "8.16.0", "acorn": "^8.11.3",
"babel-loader": "9.2.1", "babel-loader": "^9.1.3",
"copy-webpack-plugin": "14.0.0", "copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "4.0.6", "eslint-plugin-office-addins": "^4.0.3",
"file-loader": "6.2.0", "file-loader": "^6.2.0",
"html-loader": "5.1.0", "html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "1.0.6", "html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "5.6.6", "html-webpack-plugin": "^5.6.0",
"office-addin-cli": "2.0.6", "office-addin-cli": "^2.0.3",
"office-addin-debugging": "6.0.6", "office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "2.0.6", "office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "3.0.6", "office-addin-lint": "^3.0.3",
"office-addin-manifest": "2.1.2", "office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "2.0.1", "office-addin-prettier-config": "^2.0.1",
"os-browserify": "0.3.0", "os-browserify": "^0.3.0",
"process": "0.11.10", "process": "^0.11.10",
"source-map-loader": "5.0.0", "source-map-loader": "^5.0.0",
"webpack": "5.105.4", "webpack": "^5.95.0",
"webpack-cli": "5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4" "webpack-dev-server": "5.1.0"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": { "node_modules/@apidevtools/json-schema-ref-parser": {
@@ -2113,7 +2111,9 @@
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -4319,195 +4319,44 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@nodelib/fs.scandir": {
"version": "1.4.0", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 8"
},
"funding": {
"url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@peculiar/asn1-cms": { "node_modules/@nodelib/fs.walk": {
"version": "2.7.0", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.7.0", "@nodelib/fs.scandir": "2.1.5",
"@peculiar/asn1-x509": "^2.7.0", "fastq": "^1.6.0"
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-rsa": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pfx": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">= 8"
} }
}, },
"node_modules/@peculiar/x509/node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@pkgr/core": { "node_modules/@pkgr/core": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4521,6 +4370,19 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4722,6 +4584,16 @@
"form-data": "^4.0.4" "form-data": "^4.0.4"
} }
}, },
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/office-js": { "node_modules/@types/office-js": {
"version": "1.0.582", "version": "1.0.582",
"resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz", "resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz",
@@ -5778,21 +5650,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -6264,16 +6121,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/bytestreamjs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6839,20 +6686,21 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/copy-webpack-plugin": { "node_modules/copy-webpack-plugin": {
"version": "14.0.0", "version": "12.0.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.1", "glob-parent": "^6.0.1",
"globby": "^14.0.0",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"schema-utils": "^4.2.0", "schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3", "serialize-javascript": "^6.0.2"
"tinyglobby": "^0.2.12"
}, },
"engines": { "engines": {
"node": ">= 20.9.0" "node": ">= 18.12.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -6863,9 +6711,9 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.49.0", "version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -8214,6 +8062,36 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8302,6 +8180,16 @@
"node": ">= 4.9.1" "node": ">= 4.9.1"
} }
}, },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/faye-websocket": { "node_modules/faye-websocket": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@@ -8870,6 +8758,37 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/globby": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.3",
"path-type": "^6.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -9077,6 +8996,23 @@
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
}, },
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -9363,43 +9299,6 @@
"node": ">=10.18" "node": ">=10.18"
} }
}, },
"node_modules/i18next": {
"version": "26.3.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -11162,6 +11061,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": { "node_modules/methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -12746,6 +12655,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pathval": { "node_modules/pathval": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
@@ -12881,24 +12803,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pkijs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
"asn1js": "^3.0.6",
"bytestreamjs": "^2.0.1",
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -13082,26 +12986,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -13118,6 +13002,37 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -13604,6 +13519,17 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -13636,6 +13562,30 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -13830,17 +13780,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/selfsigned": { "node_modules/selfsigned": {
"version": "5.5.0", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/x509": "^1.14.2", "@types/node-forge": "^1.3.0",
"pkijs": "^3.3.3" "node-forge": "^1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=10"
} }
}, },
"node_modules/semver": { "node_modules/semver": {
@@ -13909,13 +13859,13 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.0.5", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "dependencies": {
"node": ">=20.0.0" "randombytes": "^2.1.0"
} }
}, },
"node_modules/serve-index": { "node_modules/serve-index": {
@@ -14327,6 +14277,19 @@
"simple-concat": "^1.0.0" "simple-concat": "^1.0.0"
} }
}, },
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sockjs": { "node_modules/sockjs": {
"version": "0.3.24", "version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -15074,26 +15037,6 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -15250,7 +15193,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -15385,6 +15328,19 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -15715,16 +15671,15 @@
} }
}, },
"node_modules/webpack-dev-server": { "node_modules/webpack-dev-server": {
"version": "5.2.4", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.1.0.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", "integrity": "sha512-aQpaN81X6tXie1FoOB7xlMfCsN19pSvRAeYUHOdFWOlhpQ/LlbfTqYwwmEDFV0h8GGuqmCmKmT+pxcUV/Nt2gQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/bonjour": "^3.5.13", "@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4", "@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.25", "@types/express": "^4.17.21",
"@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4", "@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5", "@types/serve-static": "^1.15.5",
"@types/sockjs": "^0.3.36", "@types/sockjs": "^0.3.36",
@@ -15733,17 +15688,18 @@
"bonjour-service": "^1.2.1", "bonjour-service": "^1.2.1",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"colorette": "^2.0.10", "colorette": "^2.0.10",
"compression": "^1.8.1", "compression": "^1.7.4",
"connect-history-api-fallback": "^2.0.0", "connect-history-api-fallback": "^2.0.0",
"express": "^4.22.1", "express": "^4.19.2",
"graceful-fs": "^4.2.6", "graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.9", "html-entities": "^2.4.0",
"http-proxy-middleware": "^2.0.3",
"ipaddr.js": "^2.1.0", "ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1", "launch-editor": "^2.6.1",
"open": "^10.0.3", "open": "^10.0.3",
"p-retry": "^6.2.0", "p-retry": "^6.2.0",
"schema-utils": "^4.2.0", "schema-utils": "^4.2.0",
"selfsigned": "^5.5.0", "selfsigned": "^2.4.1",
"serve-index": "^1.9.1", "serve-index": "^1.9.1",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"spdy": "^4.0.2", "spdy": "^4.0.2",
@@ -15772,19 +15728,6 @@
} }
} }
}, },
"node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": {
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "node_modules/webpack-dev-server/node_modules/define-lazy-prop": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@@ -15799,9 +15742,9 @@
} }
}, },
"node_modules/webpack-dev-server/node_modules/ipaddr.js": { "node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.4.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -15828,9 +15771,9 @@
} }
}, },
"node_modules/webpack-dev-server/node_modules/ws": { "node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.20.1", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3,7 +3,7 @@
"version": "0.0.1", "version": "0.0.1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/suitenumerique/meet.git" "url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
}, },
"license": "MIT", "license": "MIT",
"config": { "config": {
@@ -26,40 +26,38 @@
"watch": "webpack --mode development --watch" "watch": "webpack --mode development --watch"
}, },
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "^3.36.0",
"i18next": "26.3.2", "regenerator-runtime": "^0.14.1"
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "7.29.0", "@babel/preset-env": "^7.25.4",
"@types/office-js": "1.0.582", "@types/office-js": "^1.0.377",
"@types/office-runtime": "1.0.36", "@types/office-runtime": "^1.0.35",
"acorn": "8.16.0", "acorn": "^8.11.3",
"babel-loader": "9.2.1", "babel-loader": "^9.1.3",
"copy-webpack-plugin": "14.0.0", "copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "4.0.6", "eslint-plugin-office-addins": "^4.0.3",
"file-loader": "6.2.0", "file-loader": "^6.2.0",
"html-loader": "5.1.0", "html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "1.0.6", "html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "5.6.6", "html-webpack-plugin": "^5.6.0",
"office-addin-cli": "2.0.6", "office-addin-cli": "^2.0.3",
"office-addin-debugging": "6.0.6", "office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "2.0.6", "office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "3.0.6", "office-addin-lint": "^3.0.3",
"office-addin-manifest": "2.1.2", "office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "2.0.1", "office-addin-prettier-config": "^2.0.1",
"os-browserify": "0.3.0", "os-browserify": "^0.3.0",
"process": "0.11.10", "process": "^0.11.10",
"source-map-loader": "5.0.0", "source-map-loader": "^5.0.0",
"webpack": "5.105.4", "webpack": "^5.95.0",
"webpack-cli": "5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4" "webpack-dev-server": "5.1.0"
}, },
"prettier": "office-addin-prettier-config", "prettier": "office-addin-prettier-config",
"browserslist": [ "browserslist": [
"last 2 versions", "last 2 versions",
"ie 11" "ie 11"
] ]
} }
@@ -1,11 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title data-app-name></title>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" /> <meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script> <script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head> </head>
<body></body> <body></body>
</html> </html>
@@ -0,0 +1,80 @@
/* 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
@@ -0,0 +1,99 @@
/* global Office */
const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable
// ─── 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 };
@@ -35,6 +35,16 @@ body {
line-height: 1.5; line-height: 1.5;
} }
.learn-more {
color: #0063CB;
text-decoration: none;
font-size: 13px;
}
.learn-more:hover {
text-decoration: underline;
}
.divider { .divider {
border: none; border: none;
border-top: 1px solid #e5e5e5; border-top: 1px solid #e5e5e5;
@@ -115,77 +125,10 @@ button {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
/* ── Danger button (remove meeting) ── */ /* ── Status ── */
#status {
#btn-remove { font-size: 12px;
background-color: #CA3632; /* error.400 */ color: #666;
color: #FFFFFF; min-height: 18px;
border: none; display: none; // uncomment if you want to see the status
} }
#btn-remove:hover {
background-color: #EE6A66; /* error.600 */
}
#btn-remove:active {
background-color: #F28D8A; /* error.700 */
color: #F6AFAD; /* error.200 */
}
#btn-remove:disabled {
background-color: #F6AFAD; /* error.800 */
color: #FAD2D1; /* error.900 */
cursor: not-allowed;
}
/* ── Version ── */
#version-tag {
position: fixed;
bottom: 8px;
left: 8px;
right: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
user-select: none;
pointer-events: none;
}
#feedback-link {
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
text-decoration: underline;
pointer-events: all; /* override parent's pointer-events: none */
cursor: pointer;
}
#feedback-link:hover {
color: #374151;
}
#footer-right {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.version-badge {
background: #EEF1F4;
color: #2845C1;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
}
.version-number {
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,58 @@
<!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 nonce="NONCE_PLACEHOLDER" 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>
@@ -0,0 +1,223 @@
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;
}
// ─── Polling ──────────────────────────────────────────────────────────────
function startPolling(session_id, { onSuccess, onTimeout, onError }) {
let pollCount = 0;
const pollInterval = setInterval(() => {
// ─── Timeout after 3 minutes ──────────────────────────────
if (pollCount++ > 180) {
clearInterval(pollInterval);
onTimeout?.();
return;
}
fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id }),
})
.then((res) => res.json())
.then((sessionData) => {
console.log("Polling:", sessionData);
if (sessionData.state === "authenticated" && sessionData.access_token) {
clearInterval(pollInterval);
onSuccess?.(sessionData);
}
})
.catch((err) => {
clearInterval(pollInterval);
onError?.(err);
});
}, 1000);
return pollInterval;
}
// ─── Transit Dialog ───────────────────────────────────────────────────────
function openTransitDialog(transit_token, { onCancel, onError }) {
const meetUrl = `${BASE_URL}/addons/transit/?transit_token=${transit_token}`;
Office.context.ui.displayDialogAsync(
meetUrl,
{ height: 60, width: 50, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
onError?.(asyncResult.error);
return;
}
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
onCancel?.();
dialog.close();
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
if (arg.error === 12006) {
setStatus("Dialog fermé. En attente d'authentification...");
}
});
return dialog;
}
);
}
// ─── 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;
const transit_token = data.transit_token;
setStatus("En attente d'authentification...");
const pollInterval = startPolling(session_id, {
onSuccess: (sessionData) => {
saveSession(sessionData);
setStatus("Connecté !");
showView("auth");
},
onTimeout: () => {
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
showView("unauth");
},
onError: (err) => {
setStatus(`Erreur de polling: ${err.message}`);
},
});
openTransitDialog(transit_token, {
onCancel: () => clearInterval(pollInterval),
onError: (err) => {
clearInterval(pollInterval);
setStatus(`Erreur dialog: ${err.message}`);
},
});
})
.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");
}
}
});
@@ -5,20 +5,22 @@ const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin");
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin"); const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
const urlDev = "https://localhost:3000/";
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
async function getHttpsOptions() { async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions(); const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert }; return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
} }
module.exports = async (env, options) => { module.exports = async (env, options) => {
const dev = options.mode === "development";
const config = { const config = {
devtool: "source-map", devtool: "source-map",
entry: { entry: {
polyfill: ["core-js/stable", "regenerator-runtime/runtime"], polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"], taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
commands: "./src/commands/commands.js", commands: "./src/commands/commands.js",
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
success: ["./src/success/success.js", "./src/success/success.html"],
}, },
output: { output: {
clean: true, clean: true,
@@ -38,20 +40,7 @@ module.exports = async (env, options) => {
{ {
test: /\.html$/, test: /\.html$/,
exclude: /node_modules/, exclude: /node_modules/,
use: { use: "html-loader",
loader: "html-loader",
options: {
sources: {
urlFilter: (attribute, value) => {
// Don't try to resolve the runtime-injected config
if (value.includes("config.js")) {
return false;
}
return true;
},
},
},
},
}, },
{ {
test: /\.(png|jpg|jpeg|gif|ico)$/, test: /\.(png|jpg|jpeg|gif|ico)$/,
@@ -77,7 +66,18 @@ module.exports = async (env, options) => {
{ {
from: "assets/*", from: "assets/*",
to: "assets/[name][ext][query]", 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({ new HtmlWebpackPlugin({
@@ -89,24 +89,6 @@ module.exports = async (env, options) => {
nonce: "NONCE_PLACEHOLDER", nonce: "NONCE_PLACEHOLDER",
}, },
}), }),
new HtmlWebpackPlugin({
filename: "transit.html",
template: "./src/transit/transit.html",
chunks: ["polyfill", "transit"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new HtmlWebpackPlugin({
filename: "success.html",
template: "./src/success/success.html",
chunks: ["polyfill", "success"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new htmlWebpackInjectAttributesPlugin(), new htmlWebpackInjectAttributesPlugin(),
], ],
devServer: { devServer: {
@@ -125,4 +107,4 @@ module.exports = async (env, options) => {
}; };
return config; return config;
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

-378
View File
@@ -1,378 +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>1.0.0.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
<Description DefaultValue="Ajoutez facilement un lien de réunion __APP_NAME__ à 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://lasuite.crisp.help/fr/category/visio-15sakkg/"/>
<AppDomains>
<AppDomain>https://localhost:3000/</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="msgComposeGenerateLinkButton">
<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>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<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="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.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"/>
<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>
<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="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/assets/add-80.png"/>
<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>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.8">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<SupportsSharedFolders>true</SupportsSharedFolders>
<!-- ─── 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="msgComposeGenerateLinkButton">
<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>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<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="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.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"/>
<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>
<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="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
-134
View File
@@ -1,134 +0,0 @@
/* global Office */
const { APP_NAME } = require("../common/index");
const { createRoom, initSession } = require("../common/api");
const { startPolling } = require("../common/polling");
const { saveSession, loadSession } = require("../common/session");
const { openTransitDialog } = require("../common/transitDialog");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { applyAppName } = require("../common/helpers");
const { initI18n, t } = require("../common/i18n");
const { isMeetingAlreadyAdded } = require("../common/meetingDetector");
Office.onReady(async function (info) {
await initI18n()
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
});
function notify(message) {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message,
persistent: false,
icon: "Icon.16x16",
});
}
function insertMeetingLink(event, session) {
const item = Office.context.mailbox.item;
isMeetingAlreadyAdded(item)
.then((alreadyAdded) => {
if (alreadyAdded) {
notify(t("meeting.already_added", { app_name: APP_NAME }));
event.completed();
return;
}
return _doInsertMeetingLink(event, session);
})
.catch((err) => {
notify(t("meeting.error.details", { message: err.message }));
event.completed();
});
}
function _doInsertMeetingLink(event, session) {
createRoom(session)
.then((data) => {
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
const { url, text } = buildMeetingMessage(data, isWeb);
const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => {
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: setResult.error.message }));
resolve();
return;
}
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify(t("meeting.link_inserted"));
resolve();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: locationResult.error.message }));
} else {
notify(t("meeting.link_inserted"));
}
resolve();
});
});
});
})
.catch((err) => {
notify(`Erreur : ${err.message}`);
})
.finally(() => {
event.completed();
});
}
function connect(event) {
initSession()
.then((data) => {
const stopPolling = startPolling(data.csrf_token, {
onSuccess: (sessionData) => {
saveSession(sessionData).then(() => {
insertMeetingLink(event, sessionData);
});
},
onTimeout: () => {
notify(t("meeting.error.auth"));
event.completed();
},
onError: (err) => {
notify(t("meeting.error.retry"));
event.completed();
},
});
openTransitDialog(data.transit_token, {
onCancel: () => {
stopPolling();
event.completed();
},
onError: (err) => {
stopPolling();
event.completed();
},
});
})
.catch((err) => {
notify(t("meeting.error.details", { message: err.message }));
event.completed();
});
}
function generateMeetingLink(event) {
const session = loadSession();
if (session?.access_token) {
insertMeetingLink(event, session);
} else {
connect(event);
}
}
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLink);
Office.actions.associate("generateMeetingLinkFromMail", generateMeetingLink);
-82
View File
@@ -1,82 +0,0 @@
const { URLS } = require("./urls");
function getCsrfToken() {
return document.cookie
.split(";")
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
.map((cookie) => cookie.split("=")[1])
.pop();
}
function authHeaders(session) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
};
}
/**
* Builds headers for CSRF-protected requests.
*
* Two CSRF flows coexist in this addon:
*
* 1. Cookie-based (Django default): used by `exchange`, called from the
* OAuth success page in a normal browser context. Django's CSRF
* middleware has already set the `csrftoken` cookie via the auth
* redirect, so we read it from `document.cookie` and echo it back
* as `X-CSRFToken`. The middleware verifies the header matches the
* cookie. No `csrfToken` argument needed — `getCsrfToken()` handles it.
*
* 2. Body-passed token: used by `poll`, called from the Office dialog /
* taskpane iframe. Cookie access inside Office iframes is unreliable
* across Outlook clients, so we can't depend on `document.cookie`
* being populated. Instead, `init` returns the CSRF token in its JSON
* response body, and callers pass it explicitly to subsequent calls.
* The token still travels as `X-CSRFToken` — only its source differs.
*
* The `csrfToken` parameter takes precedence when provided; falls back
* to the cookie when omitted.
*/
function csrfHeaders(csrfToken) {
const token = csrfToken || getCsrfToken();
return {
"Content-Type": "application/json",
...(token && { "X-CSRFToken": token }),
};
}
async function request(path, { session, csrf, csrfToken, ...opts } = {}) {
const headers = {
...(session && authHeaders(session)),
...(csrf && csrfHeaders(csrfToken)),
...opts.headers,
};
const res = await fetch(path, {
...opts,
headers,
credentials: csrf ? "include" : opts.credentials,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
module.exports = {
initSession: () => request(URLS.init, { method: "POST" }),
pollSession: (csrfToken) =>
request(URLS.poll, {
method: "POST",
csrf: true,
csrfToken,
}),
exchangeSession: (transitToken) =>
request(URLS.exchange, {
method: "POST",
csrf: true,
body: JSON.stringify({ transit_token: transitToken }),
}),
createRoom: (session) =>
request(URLS.rooms, {
method: "POST",
session,
}),
};
-16
View File
@@ -1,16 +0,0 @@
const { APP_NAME } = require("./index");
function isOfficeReady() {
return typeof Office !== "undefined" && Office?.context?.roamingSettings != null;
}
function applyAppName() {
document.querySelectorAll("[data-app-name]").forEach((el) => {
el.textContent = APP_NAME;
});
}
module.exports = {
isOfficeReady,
applyAppName,
};
-48
View File
@@ -1,48 +0,0 @@
const { APP_NAME } = require("../common");
const i18nextModule = require("i18next");
const i18next = i18nextModule.default || i18nextModule;
const fr = require("../locales/fr/translation.json");
const en = require("../locales/en/translation.json");
const de = require("../locales/de/translation.json");
async function initI18n() {
const lng = typeof Office !== "undefined" ? Office.context.displayLanguage : navigator.language;
await i18next.init({
lng,
fallbackLng: "fr",
interpolation: { escapeValue: false },
resources: {
fr: { translation: fr },
en: { translation: en },
de: { translation: de },
},
});
}
function t(key, vars) {
return i18next.t(key, vars);
}
function translateUI() {
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
el.textContent = t(key, { app_name: APP_NAME });
});
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
const pairs = el.getAttribute("data-i18n-attr").split(",");
pairs.forEach((pair) => {
const [attr, key] = pair.split(":");
el.setAttribute(attr, t(key, { app_name: APP_NAME }));
});
});
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
el.setAttribute("aria-label", t(el.getAttribute("data-i18n-aria")));
});
}
module.exports = { initI18n, t, translateUI };
-11
View File
@@ -1,11 +0,0 @@
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
const ENABLE_SOURCE_TRACKING = window.__APP_CONFIG__?.ENABLE_SOURCE_TRACKING === "true";
const FEEDBACK_FORM = window.__APP_CONFIG__?.FEEDBACK_FORM || null;
module.exports = {
BASE_URL,
APP_NAME,
ENABLE_SOURCE_TRACKING,
FEEDBACK_FORM
};
@@ -1,107 +0,0 @@
const { BASE_URL } = require("./index");
/**
* Returns a promise that resolves to true if a meeting link is already present
*/
function isMeetingAlreadyAdded(item) {
return Promise.all([_checkBody(item), _checkLocation(item)]).then(
([inBody, inLocation]) => inBody || inLocation
);
}
function _checkBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Text, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _checkLocation(item) {
// Location only exists on appointments
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _containsMeetingUrl(text) {
if (!text) return false;
return text.includes(BASE_URL);
}
function removeMeetingLink(item) {
return Promise.all([_removeFromBody(item), _removeFromLocation(item)]);
}
function _removeFromBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Html, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve();
return;
}
const cleaned = _cleanBody(result.value || "");
if (cleaned === null) {
resolve();
return;
}
item.body.setAsync(cleaned, { coercionType: Office.CoercionType.Html }, () => resolve());
});
});
}
function _removeFromLocation(item) {
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve();
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (
result.status === Office.AsyncResultStatus.Succeeded &&
_containsMeetingUrl(result.value)
) {
item.location.setAsync("", () => resolve());
} else {
resolve();
}
});
});
}
const SEPARATOR = /─{10,}/;
/**
* Returns cleaned HTML, or null if no meeting block was found.
*/
function _cleanBody(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const hits = [];
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (SEPARATOR.test(walker.currentNode.nodeValue)) hits.push(walker.currentNode);
}
if (hits.length < 2) return null;
const range = doc.createRange();
range.setStartBefore(hits[0]);
range.setEndAfter(hits[hits.length - 1]);
range.deleteContents();
return doc.documentElement.outerHTML;
}
module.exports = { isMeetingAlreadyAdded, removeMeetingLink };
@@ -1,79 +0,0 @@
const { APP_NAME, ENABLE_SOURCE_TRACKING } = require("./index");
const { t } = require("./i18n");
function _formatPin(pin) {
if (!pin) return "";
const clean = String(pin).replace(/\s+/g, "");
if (!clean) return "";
if (/^\d{10}$/.test(clean)) {
return clean.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#";
}
return clean + "#";
}
// todo - support international format
function _formatPhone(phone) {
if (!phone) return "";
const clean = String(phone).replace(/\s+/g, "");
if (/^\+33\d{9}$/.test(clean)) {
return clean.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5");
}
return clean;
}
function _appendTrackingParams(url) {
if (!ENABLE_SOURCE_TRACKING) return url;
const u = new URL(url);
u.searchParams.set("from", "outlook-addin");
return u.toString();
}
// todo - escape html / link
function buildMeetingMessage(data, isWeb) {
if (!data?.url) {
throw new Error("buildMeetingMessage: missing url in data");
}
const url = _appendTrackingParams(data.url);
const phone = _formatPhone(data.telephony?.phone_number);
const pin = _formatPin(data.telephony?.pin_code);
let textLines = "";
let phoneLines = [];
const join = t("meeting_message.join", { app_name: APP_NAME });
const phoneOnly = t("meeting_message.phone_only");
const phoneFr = t("meeting_message.phone_fr", { phone });
const pinCode = t("meeting_message.pin_code", { pin });
if (isWeb) {
phoneLines = phone && pin ? [`<br><br>${phoneOnly}`, `<br>${phoneFr}`, `<br>${pinCode}`] : [];
textLines = [
"<br><br>────────────────────────────────────────",
`<br>${join}`,
`<br><br><a href="${url}" target="_blank">${url}</a>`,
...phoneLines,
"<br>────────────────────────────────────────<br>",
];
} else {
phoneLines = phone && pin ? [`\n\n${phoneOnly}`, `\n${phoneFr}`, `\n${pinCode}`] : [];
textLines = [
"\n\n────────────────────────────────────────",
`\n${join}`,
`\n\n${url}`,
...phoneLines,
"\n────────────────────────────────────────\n",
];
}
const text = textLines.join("");
return { url, text };
}
module.exports = { buildMeetingMessage };
-47
View File
@@ -1,47 +0,0 @@
const { pollSession } = require("./api");
const POLLING_INTERVAL_MS = 1000;
const POLLING_TIMEOUT_MS = 3 * 60 * 1000;
const POLLING_MAX_ATTEMPTS = POLLING_TIMEOUT_MS / POLLING_INTERVAL_MS;
function isPollAuthenticated(sessionData) {
return sessionData.state === "authenticated" && sessionData.access_token;
}
function startPolling(csrfToken, { onSuccess, onTimeout, onError }) {
let pollCount = 0;
let timeoutId = null;
let cancelled = false;
const poll = () => {
if (pollCount++ >= POLLING_MAX_ATTEMPTS) {
onTimeout?.();
return;
}
pollSession(csrfToken)
.then((sessionData) => {
if (cancelled) return;
if (isPollAuthenticated(sessionData)) {
onSuccess?.(sessionData);
return;
}
timeoutId = setTimeout(poll, POLLING_INTERVAL_MS);
})
.catch((err) => {
if (cancelled) return;
onError?.(err);
});
};
poll();
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
module.exports = {
startPolling,
};
-104
View File
@@ -1,104 +0,0 @@
const { isOfficeReady } = require("./helpers");
const SESSION_KEY = "meetSession";
// DEV NOTE:
// Office.context.roamingSettings persists data in the user's mailbox and
// synchronizes it via Exchange across all Outlook clients (desktop, web, mobile)
// where the user signs in. This means anything stored here (including tokens)
// leaves the local device boundary and is replicated across environments.
//
// Microsoft guidance explicitly advises NOT storing secrets (e.g., OAuth access
// tokens, refresh tokens, or other sensitive credentials) in roamingSettings,
// as it is not a secure storage mechanism and lacks OS-level protections.
//
// That said, for the current alpha version we accept this trade-off for simplicity,
// with the expectation that a more secure approach (e.g., in-memory tokens) will replace this.
function saveSession(data) {
if (!isOfficeReady()) {
return Promise.reject(new Error("Office not ready"));
}
if (!data || !data.access_token) {
return Promise.reject(new Error("Missing access_token"));
}
const expiresInSeconds = Number(data.expires_in);
const expiresAt =
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
: null;
const payload = JSON.stringify({
...data,
expiresAt,
savedAt: new Date().toISOString(),
});
return new Promise((resolve, reject) => {
const rs = Office.context.roamingSettings;
rs.set(SESSION_KEY, payload);
rs.saveAsync((result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(new Error(result.error?.message || "saveAsync failed"));
}
});
});
}
function loadSession() {
if (!isOfficeReady()) {
return null;
}
let session = null;
try {
const stored = Office.context.roamingSettings.get(SESSION_KEY);
if (stored) session = JSON.parse(stored);
} catch (e) {
clearSession();
return null;
}
if (!session) return null;
// Fail closed if expiry is missing — backend is expected to send expires_in.
if (!session.expiresAt) {
clearSession();
return null;
}
const expiresTs = Date.parse(session.expiresAt);
if (!Number.isFinite(expiresTs) || Date.now() >= expiresTs) {
clearSession();
return null;
}
return session;
}
function clearSession() {
if (!isOfficeReady()) {
return Promise.resolve();
}
return new Promise((resolve) => {
try {
const rs = Office.context.roamingSettings;
rs.remove(SESSION_KEY);
rs.saveAsync((result) => {
resolve();
});
} catch (e) {
resolve();
}
});
}
module.exports = {
saveSession,
loadSession,
clearSession,
};
@@ -1,43 +0,0 @@
const { URLS } = require("./urls");
const DIALOG_SIGNALS = {
ready: "ready",
done: "done",
};
const DIALOG_HEIGHT = 60;
const DIALOG_WIDTH = 50;
function openTransitDialog(transitToken, { onCancel, onError }) {
Office.context.ui.displayDialogAsync(
URLS.transitDialog,
{ height: DIALOG_HEIGHT, width: DIALOG_WIDTH, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
onError?.(asyncResult.error);
return;
}
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg) => {
if (arg.message === DIALOG_SIGNALS.ready) {
dialog.messageChild(transitToken);
return;
}
if (arg.message === DIALOG_SIGNALS.done) {
return;
}
onCancel?.();
dialog.close();
});
return dialog;
}
);
}
module.exports = {
openTransitDialog,
DIALOG_SIGNALS,
};
@@ -1,18 +0,0 @@
const TRANSIT_TOKEN_KEY = "transitToken";
function save(token) {
sessionStorage.setItem(TRANSIT_TOKEN_KEY, token);
}
function consume() {
try {
const token = sessionStorage.getItem(TRANSIT_TOKEN_KEY);
sessionStorage.removeItem(TRANSIT_TOKEN_KEY);
return token;
} catch (err) {
console.error("Failed to read transit token:", err);
return null;
}
}
module.exports = { save, consume };
-15
View File
@@ -1,15 +0,0 @@
const { BASE_URL } = require("./index");
const ADDONS_BASE_URL = `${BASE_URL}/api/v1.0/addons/sessions`;
const URLS = {
authenticate: `${BASE_URL}/api/v1.0/authenticate/`,
successPage: `${BASE_URL}/addons/outlook/success.html`,
transitDialog: `${BASE_URL}/addons/outlook/transit.html`,
init: `${ADDONS_BASE_URL}/init/`,
poll: `${ADDONS_BASE_URL}/poll/`,
exchange: `${ADDONS_BASE_URL}/exchange/`,
rooms: `${BASE_URL}/external-api/v1.0/rooms/`,
};
module.exports = { URLS };
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Laden Sie das Add-In.",
"loading": "Wird geladen..."
},
"unauth": {
"intro": "Fügen Sie Ihren Outlook-Terminen ganz einfach einen {{app_name}}-Besprechungslink hinzu.",
"proconnect_btn": "Aanmelden met ProConnect",
"proconnect_link": "Wat is ProConnect?",
"proconnect_link_title": "Wat is ProConnect? - nieuw venster"
},
"success": {
"close_window": "Falls sich dieses Fenster nicht automatisch schließt, schließen Sie es bitte manuell."
},
"auth": {
"disconnect": "Abmelden"
},
"meeting": {
"already_added": "Es wurde bereits ein {{app_name}}-Meeting hinzugefügt.",
"link_inserted": "Besprechungslink erfolgreich eingefügt",
"generating": "Wird erstellt...",
"add_meeting": "{{app_name}}-Besprechung hinzufügen",
"remove_meeting": "{{app_name}}-Besprechung entfernen",
"removing": "Wird entfernt...",
"error": {
"auth": "Ihre Sitzung ist abgelaufen. Bitte versuchen Sie es erneut.",
"retry": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"details": "Fehler: {{message}}"
}
},
"meeting_message": {
"join": "An der {{app_name}}-Besprechung teilnehmen",
"phone_only": "Oder per Telefon teilnehmen (nur Audio)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Teilen Sie uns Ihr Feedback mit"
}
}
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Please load the add-in.",
"loading": "Loading..."
},
"unauth": {
"intro": "Easily add a {{app_name}} meeting link to your Outlook events.",
"proconnect_btn": "Sign in with ProConnect",
"proconnect_link": "What is ProConnect?",
"proconnect_link_title": "What is ProConnect? - new window"
},
"success": {
"close_window": "If this window does not close automatically, please close it manually."
},
"auth": {
"disconnect": "Sign out"
},
"meeting": {
"already_added": "A {{app_name}} meeting has already been added.",
"link_inserted": "Meeting link inserted successfully",
"generating": "Generating...",
"add_meeting": "Add a {{app_name}} meeting",
"remove_meeting": "Remove the {{app_name}} meeting",
"removing": "Removing...",
"error": {
"auth": "Your session has expired. Please try again.",
"retry": "An error occurred. Please try again.",
"details": "Error: {{message}}"
}
},
"meeting_message": {
"join": "Join the {{app_name}} meeting",
"phone_only": "Or call in (audio only)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Share your feedback"
}
}
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Veuillez charger le complément.",
"loading": "Chargement..."
},
"unauth": {
"intro": "Ajoutez facilement un lien de réunion {{app_name}} à vos événements Outlook.",
"proconnect_btn": "S'identifier avec ProConnect",
"proconnect_link": "Qu'est-ce que ProConnect ?",
"proconnect_link_title": "Qu'est-ce que ProConnect ? - nouvelle fenêtre"
},
"success": {
"close_window": "Si cette fenêtre ne se ferme pas toute seule, veuillez la fermer manuellement."
},
"auth": {
"disconnect": "Se déconnecter"
},
"meeting": {
"already_added": "Une réunion {{app_name}} a déjà été ajoutée.",
"link_inserted": "Lien de réunion inséré avec succès",
"generating": "Génération...",
"add_meeting": "Ajouter une réunion {{app_name}}",
"remove_meeting": "Supprimer la réunion {{app_name}}",
"removing": "Suppression en cours...",
"error": {
"auth": "Connexion expirée, veuillez réessayer.",
"retry": "Une erreur est survenue, veuillez ré-essayer",
"details": "Erreur : {{message}}"
}
},
"meeting_message": {
"join": "Rejoindre la réunion {{app_name}}",
"phone_only": "Ou appelez (audio uniquement)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Partagez-nous vos retours"
}
}
-81
View File
@@ -1,81 +0,0 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#sideload-msg {
display: none;
}
#status {
display: none;
}
.spinner-container {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
}
.spinner-svg {
width: 56px;
height: 56px;
}
/* Background arc (light gray ring) */
.spinner-track {
stroke: #E5E7EB; /* primary.100 equivalent */
fill: none;
stroke-width: 3;
stroke-linecap: round;
}
/* Foreground rotating arc */
.spinner-arc {
stroke: #000091; /* primary.800 equivalent */
fill: none;
stroke-width: 3;
stroke-linecap: round;
/* circumference = 2 * PI * r where r = 11 -> ~69.115 */
/* show 30% -> dashoffset = c - 0.3 * c = ~48.38 */
stroke-dasharray: 69.115 69.115;
stroke-dashoffset: 48.38;
transform-origin: center;
animation: spinner-rotate 1s ease-in-out infinite;
}
@keyframes spinner-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Hourglass fallback for reduced motion */
.spinner-fallback {
display: none;
color: #000091;
}
@media (prefers-reduced-motion: reduce) {
.spinner-svg {
display: none;
}
.spinner-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
@@ -1,45 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<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 data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div class="spinner-container"
role="progressbar"
data-i18n-aria="app.loading"
>
<svg class="spinner-svg"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background track -->
<circle class="spinner-track" cx="14" cy="14" r="11"
/>
<!-- Rotating arc -->
<circle class="spinner-arc" cx="14" cy="14" r="11"
/>
</svg>
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
<span class="spinner-fallback" aria-hidden="true">
<svg width="22"
height="22"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style="display: block; transform: translateY(1px);"
>
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
</svg>
</span>
</div>
<p id="close-msg" style="display: none; text-align: center; font-size: 13px; color: #666; margin-top: 16px;" data-i18n="success.close_window"></p>
</body>
</html>
-35
View File
@@ -1,35 +0,0 @@
const { applyAppName } = require("../common/helpers");
const { exchangeSession } = require("../common/api");
const { consume } = require("../common/transitToken");
const { initI18n, translateUI } = require("../common/i18n");
(async () => {
await initI18n();
applyAppName();
translateUI();
const transitToken = consume();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.then(() => {
document.querySelector(".spinner-container").style.display = "none";
document.querySelector("#close-msg").style.display = "block";
})
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
// this window wasn't opened by this script (it was opened externally),
// so it blocks window.close() for security reasons. The "#close-msg"
// shown above is the fallback for that case.g
window.close();
});
}
})();
@@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<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 data-app-name></title>
<link rel="stylesheet" href="taskpane.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text" data-i18n="app.loading"></p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span data-i18n="unauth.intro"></span>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only" data-i18n="unauth.proconnect_btn"></span>
</button>
<p>
<a href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
data-i18n-attr="title:unauth.proconnect_link_title"
data-i18n="unauth.proconnect_link"
></a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<!-- shown when no meeting is present -->
<button id="btn-generate" data-i18n="meeting.add_meeting"></button>
<!-- shown when a meeting is already present -->
<button id="btn-remove" style="display:none;" data-i18n="meeting.remove_meeting"></button>
<button id="btn-disconnect" data-i18n="auth.disconnect"></button>
</div>
</div>
</div>
<footer id="version-tag">
<a id="feedback-link"
style="display:none;"
target="_blank"
rel="noopener noreferrer"
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-number">1.0.0</span>
</div>
</footer>
</body>
</html>
-199
View File
@@ -1,199 +0,0 @@
/* global Office */
const { APP_NAME, FEEDBACK_FORM } = require("../common");
const { applyAppName } = require("../common/helpers");
const { initSession, createRoom } = require("../common/api");
const { startPolling } = require("../common/polling");
const { openTransitDialog } = require("../common/transitDialog");
const { loadSession, saveSession, clearSession } = require("../common/session");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { initI18n, t, translateUI } = require("../common/i18n");
const { isMeetingAlreadyAdded, removeMeetingLink } = require("../common/meetingDetector");
// ── 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";
if (name === "auth") {
_refreshMeetingButtonState();
}
}
// ── Button state ─────────────────────────────────────────────
function _showAddButton() {
document.getElementById("btn-generate").style.display = "block";
document.getElementById("btn-remove").style.display = "none";
}
function _showRemoveButton() {
document.getElementById("btn-generate").style.display = "none";
document.getElementById("btn-remove").style.display = "block";
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = t("meeting.generating");
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = t("meeting.add_meeting", { app_name: APP_NAME });
}
function _setRemoveLoading() {
const btn = document.getElementById("btn-remove");
btn.disabled = true;
btn.textContent = t("meeting.removing");
}
function _setRemoveIdle() {
const btn = document.getElementById("btn-remove");
btn.disabled = false;
btn.textContent = t("meeting.remove_meeting", { app_name: APP_NAME });
}
function _refreshMeetingButtonState() {
const item = Office.context.mailbox.item;
if (!item) return;
isMeetingAlreadyAdded(item).then((alreadyAdded) => {
if (alreadyAdded) {
_showRemoveButton();
} else {
_showAddButton();
}
});
}
// ── Auth ─────────────────────────────────────────────────────
function connect() {
initSession()
.then((data) => {
const stopPolling = startPolling(data.csrf_token, {
onSuccess: (sessionData) => {
saveSession(sessionData).then(() => showView("auth"));
},
onTimeout: () => {
showView("unauth");
},
onError: (err) => {
console.error(err);
},
});
openTransitDialog(data.transit_token, {
onCancel: () => stopPolling(),
onError: (err) => {
stopPolling();
},
});
})
.catch((err) => {
console.error(err);
});
}
function disconnect() {
clearSession().finally(() => showView("unauth"));
}
// ── Meeting ──────────────────────────────────────────────────
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
showView("unauth");
return;
}
_setButtonLoading();
createRoom(session)
.then((data) => {
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
const { url, text } = buildMeetingMessage(data, isWeb);
const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => {
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
});
});
})
.then(() => {
_showRemoveButton();
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setButtonIdle();
});
}
function removeMeetingLinkFromItem() {
const session = loadSession();
if (!session?.access_token) {
showView("unauth");
return;
}
_setRemoveLoading();
const item = Office.context.mailbox.item;
removeMeetingLink(item)
.then(() => {
_showAddButton();
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setRemoveIdle();
});
}
// ── Init ─────────────────────────────────────────────────────
Office.onReady(async (info) => {
await initI18n();
translateUI();
if (FEEDBACK_FORM) {
const link = document.getElementById("feedback-link");
link.href = FEEDBACK_FORM;
link.style.display = "inline";
}
if (info.host === Office.HostType.Outlook) {
applyAppName();
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;
document.getElementById("btn-remove").onclick = removeMeetingLinkFromItem;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
showView("auth"); // this already calls _refreshMeetingButtonState internally
} else {
showView("unauth");
}
}
});
@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<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 data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div
class="spinner-container"
role="progressbar"
data-i18n-aria="app.loading"
>
<svg
class="spinner-svg"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background track -->
<circle class="spinner-track" cx="14" cy="14" r="11"
/>
<!-- Rotating arc -->
<circle class="spinner-arc" cx="14" cy="14" r="11"
/>
</svg>
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
<span class="spinner-fallback" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style="display: block; transform: translateY(1px);"
>
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
</svg>
</span>
</div>
</body>
</html>
-58
View File
@@ -1,58 +0,0 @@
const { applyAppName } = require("../common/helpers");
const { URLS } = require("../common/urls");
const { save } = require("../common/transitToken");
const { DIALOG_SIGNALS } = require("../common/transitDialog");
const { initI18n, translateUI } = require("../common/i18n");
// Initiate the authentication flow, then return to the success page
function getAuthenticateUrl() {
const url = new URL(URLS.authenticate);
url.searchParams.set("returnTo", URLS.successPage);
return url.toString();
}
Office.onReady(async function (info) {
await initI18n();
translateUI();
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
Office.context.ui.addHandlerAsync(
Office.EventType.DialogParentMessageReceived,
function (arg) {
const transitToken = arg.message;
if (typeof transitToken !== "string" || transitToken.trim() === "") {
console.error("Invalid transit token received from parent dialog.");
return;
}
// Runs inside the dialog window.
// Flow:
// transit.html saves token → navigates to /authenticate → OAuth redirect →
// success.html. sessionStorage survives because it's per-window-per-origin
// and the dialog window persists across same-origin navigations.
// Fragile: if the IdP opens the redirect in a new tab/window, this breaks
// silently.
// An alternative could be to pass the token via the OAuth `state` param
// and read it back from the redirect URL.
try {
save(transitToken);
Office.context.ui.messageParent(DIALOG_SIGNALS.done);
window.location.href = getAuthenticateUrl();
} catch (err) {
console.error("Failed to store transit token:", err);
}
},
function (result) {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("Failed to register DialogParentMessageReceived handler.", result.error);
return;
}
Office.context.ui.messageParent(DIALOG_SIGNALS.ready);
}
);
});
-38
View File
@@ -1,38 +0,0 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
**/.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.iml
db.sqlite3
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Env
.env
+13 -50
View File
@@ -1,72 +1,35 @@
FROM python:3.14.6-slim AS base FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit # Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libglib2.0-0 \ libglib2.0-0 \
libgobject-2.0-0 \ libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder FROM base AS builder
ENV UV_COMPILE_BYTECODE=1 \ WORKDIR /builder
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
# Install uv COPY pyproject.toml .
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app RUN mkdir /install && \
pip install --prefix=/install .
# Install production dependencies without the project itself (cacheable layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- Development image ----
FROM base AS development
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-extras
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production FROM base AS production
WORKDIR /app WORKDIR /app
# Copy the pre-built virtualenv and application source
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# Remove pip to reduce attack surface in production # Remove pip to reduce attack surface in production
RUN pip uninstall -y pip RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}
CMD ["python", "multi_user_transcriber.py", "start"] # Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
-5
View File
@@ -1,5 +0,0 @@
"""Storage parsers specific exceptions."""
class MissingConfigError(Exception):
"""Raised when a variable is not set in configuration."""
-396
View File
@@ -1,396 +0,0 @@
"""Metadata agent that extracts metadata from active room."""
import asyncio
import json
import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentServer,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomIO,
WorkerPermissions,
cli,
utils,
)
from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
logger = logging.getLogger("metadata-collector")
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(AGENT_NAME)
proc.userdata["vad"] = silero.VAD.load()
server = AgentServer(
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
server.setup_fnc = prewarm
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
participant_id: str
type: str
timestamp: datetime
data: Optional[str] = None
def serialize(self) -> dict:
"""Return a JSON-serializable dictionary representation of the event."""
data = asdict(self)
data["timestamp"] = self.timestamp.isoformat()
return data
class VADAgent(Agent):
"""Agent that monitors voice activity for a specific participant."""
def __init__(self, participant_identity: str, events: List):
"""Initialize with a participant identity and shared events list."""
super().__init__(
instructions="not-needed",
)
self.participant_identity = participant_identity
self.events = events
async def on_enter(self) -> None:
"""Initialize VAD monitoring for this participant."""
@self.session.on("user_state_changed")
def on_user_state(event):
timestamp = datetime.now(timezone.utc)
if event.new_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_start",
timestamp=timestamp,
)
self.events.append(event)
elif event.old_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_end",
timestamp=timestamp,
)
self.events.append(event)
class MetadataCollector:
"""Collect meeting events across all participants in a room.
Creates one AgentSession per participant to capture VAD events
(speech start/end), and listens for connection, disconnection,
and chat events. Persists all collected events as JSON to S3
on shutdown.
"""
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataCollector initialized")
def start(self):
"""Start listening for room-level events."""
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
logger.info("Started listening for participant events")
async def on_chat_message_received(
self, reader: rtc.TextStreamReader, participant_identity: str
):
"""Read a complete chat message and record it as an event."""
full_text = await reader.read_all()
logger.info("Received chat message from %s", participant_identity)
self.events.append(
MetadataEvent(
participant_id=participant_identity,
type="chat_received",
timestamp=datetime.now(timezone.utc),
data=full_text,
)
)
def handle_chat_stream(self, reader, participant_identity):
"""Schedule async processing of an incoming chat stream."""
task = asyncio.create_task(
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"process chat stream from {participant_identity}",
)
)
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_events],
"participants": participants,
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
async def aclose(self):
"""Close all sessions and cleanup resources."""
logger.info("Closing all VAD monitoring sessions…")
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()],
return_exceptions=True,
)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
logger.info("All VAD sessions closed")
self.save()
async def on_participant_entrypoint(
self, ctx: JobContext, participant: rtc.RemoteParticipant
):
"""Handle new participant by starting a VAD monitoring session."""
if participant.identity in self._sessions:
logger.debug("Session already exists for %s", participant.identity)
return
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_connected",
timestamp=datetime.now(timezone.utc),
)
)
self.participants[participant.identity] = participant.name
logger.info("New participant connected: %s", participant.identity)
try:
session = await self._start_session(participant)
self._sessions[participant.identity] = session
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing VAD monitoring."""
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_disconnected",
timestamp=datetime.now(timezone.utc),
)
)
session = self._sessions.pop(participant.identity, None)
if session is None:
logger.debug("No session found for %s", participant.identity)
return
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close VAD session for {participant.identity}",
on_success=lambda _: logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
),
)
)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
logger.info("Participant's name changed: %s", participant.identity)
self.participants[participant.identity] = participant.name
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start VAD monitoring session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
# Create session with VAD only - no STT, LLM, or TTS
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
turn_detection="vad",
user_away_timeout=30.0,
)
# Set up room IO to receive audio from this specific participant
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
options=lk_room_io.RoomOptions(
audio_input=lk_room_io.AudioInputOptions(),
text_input=False,
audio_output=False,
text_output=False,
),
)
await room_io.start()
await session.start(
agent=VADAgent(
participant_identity=participant.identity, events=self.events
)
)
return session
async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session."""
try:
await session.aclose()
except Exception:
logger.exception("Error closing session")
async def handle_job_request(job_req: JobRequest) -> None:
"""Accept or reject the job request based on agent presence in the room."""
room_name = job_req.room.name
recording_id = job_req.job.metadata
agent_identity = f"{AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lk:
try:
resp = await lk.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
already_present = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == agent_identity
for p in resp.participants
)
if already_present:
logger.info("Agent already in the room '%s' — reject", room_name)
await job_req.reject()
else:
logger.info(
"Accept job for '%s' — identity=%s", room_name, agent_identity
)
await job_req.accept(identity=agent_identity, metadata=recording_id)
except Exception:
logger.exception("Error treating the job for '%s'", room_name)
await job_req.reject()
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
metadata_collector.start()
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(AGENT_NAME)
cli.run_app(server)
@@ -25,9 +25,6 @@ from livekit.agents import (
) )
from livekit.plugins import deepgram, silero from livekit.plugins import deepgram, silero
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv() load_dotenv()
logger = logging.getLogger("transcriber") logger = logging.getLogger("transcriber")
@@ -102,29 +99,24 @@ class MultiUserTranscriber:
logger.info(f"starting session for {participant.identity}") logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant)) task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(
done_callback( def on_task_done(task: asyncio.Task):
logger, try:
self._tasks, self._sessions[participant.identity] = task.result()
f"start transcription session for {participant.identity}", finally:
) self._tasks.discard(task)
)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant): def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session.""" """Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity, None)) is None: if (session := self._sessions.pop(participant.identity)) is None:
return return
logger.info(f"closing session for {participant.identity}") logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session)) task = asyncio.create_task(self._close_session(session))
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback( task.add_done_callback(lambda _: self._tasks.discard(task))
done_callback(
logger,
self._tasks,
f"close transcription session for {participant.identity}",
)
)
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession: async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant.""" """Create and start transcription session for participant."""
@@ -147,7 +139,6 @@ class MultiUserTranscriber:
participant_identity=participant.identity, participant_identity=participant.identity,
) )
) )
self._sessions[participant.identity] = session
return session return session
async def _close_session(self, sess: AgentSession) -> None: async def _close_session(self, sess: AgentSession) -> None:
@@ -158,8 +149,6 @@ class MultiUserTranscriber:
async def entrypoint(ctx: JobContext): async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber.""" """Initialize and run the multi-user transcriber."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
transcriber = MultiUserTranscriber(ctx) transcriber = MultiUserTranscriber(ctx)
transcriber.start() transcriber.start()
@@ -204,15 +193,11 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess): def prewarm(proc: JobProcess):
"""Preload voice activity detection model.""" """Preload voice activity detection model."""
configure_sentry(TRANSCRIBER_AGENT_NAME)
if ENABLE_SILERO_VAD: if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load() proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__": if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(TRANSCRIBER_AGENT_NAME)
cli.run_app( cli.run_app(
WorkerOptions( WorkerOptions(
entrypoint_fnc=entrypoint, entrypoint_fnc=entrypoint,
-83
View File
@@ -1,83 +0,0 @@
"""Sentry helpers for the LiveKit agents."""
import logging
import os
import tomllib
from os import path
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
logger = logging.getLogger("observability")
BASE_DIR = path.dirname(path.abspath(__file__))
def get_release():
"""Get the current release of the application.
By release, we mean the ``version`` declared in ``pyproject.toml``.
If the file cannot be read or declares no version, it defaults to "NA".
"""
try:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
def configure_sentry(agent_name: str) -> None:
"""Initialize Sentry for the current agent process.
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
unconditionally so the calling process gets its own live transport.
Must be called once per process: in the worker entrypoint and again in the
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
forked process. A forked child inherits the parent's initialized Sentry
client but not its background transport thread (threads do not survive
``fork()``), so it must re-init to get a working transport. For that reason,
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
as ``True`` and would skip init, silently dropping every event.
Args:
agent_name: Identifier of the agent, attached as a tag to Sentry issues
"""
# Read the DSN at call time so it picks up variables that load_dotenv()
# populated after this module was first imported.
sentry_dsn = os.getenv("SENTRY_DSN")
if not sentry_dsn:
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
return
sentry_sdk.init(
dsn=sentry_dsn,
environment=os.getenv("SENTRY_ENVIRONMENT"),
release=get_release(),
debug=False,
integrations=[
# Capture log records emitted at ERROR and above as Sentry events.
# This covers the agents' explicit logger.exception(...) calls as
# well as asyncio's "Exception in callback" / "Task exception was
# never retrieved" records, so unhandled task failures surface too.
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
sentry_sdk.set_tag("application", "agents")
sentry_sdk.set_tag("agent", agent_name)
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
"""Tag the current Sentry scope with the LiveKit job being handled.
Args:
room: Name of the room the job is serving.
job_id: LiveKit job identifier.
"""
scope = sentry_sdk.get_current_scope()
if room is not None:
scope.set_tag("room", room)
if job_id is not None:
scope.set_tag("job_id", job_id)
+10 -11
View File
@@ -1,26 +1,25 @@
[project] [project]
name = "agents" name = "agents"
version = "1.24.0" version = "1.8.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.4", "livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.6.4", "livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.6.4", "livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6", "livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2", "python-dotenv==1.2.1",
"protobuf==6.33.6", "protobuf==6.33.5"
"minio==7.2.20",
"sentry-sdk==2.60.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff==0.15.19", "ruff==0.14.4",
] ]
[tool.uv] [build-system]
package = false requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff] [tool.ruff]
target-version = "py313" target-version = "py313"
-42
View File
@@ -1,42 +0,0 @@
"""Helpers for managing asyncio tasks."""
import asyncio
import logging
from collections.abc import Callable
from typing import Any
def done_callback(
logger: logging.Logger,
tasks: set[asyncio.Task],
description: str,
*,
on_success: Callable[[Any], None] | None = None,
) -> Callable[[asyncio.Task], None]:
"""Build a done-callback for a background task.
Meant to be passed to `asyncio.Task.add_done_callback`.
Args:
logger: Logger used to report failures, so records keep the caller's
logger name.
tasks: Set the task was registered in; the task is discarded from it.
description: Human-readable intended action
on_success: Optional callback invoked with the task's result when it
completes without error.
Returns:
A callback suitable for ``task.add_done_callback(...)``.
"""
def _finalize(task: asyncio.Task) -> None:
tasks.discard(task)
if task.cancelled():
return
if (exc := task.exception()) is not None:
logger.exception("failed to %s", description, exc_info=exc)
return
if on_success is not None:
on_success(task.result())
return _finalize
-2067
View File
File diff suppressed because it is too large Load Diff
+120 -272
View File
@@ -1,7 +1,5 @@
"""Authentication session management for add-ons using temporary cache-based sessions.""" """Authentication session management for add-ons using temporary cache-based sessions."""
import hashlib
import hmac
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
@@ -9,336 +7,186 @@ from logging import getLogger
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import SuspiciousOperation
from core.models import User from core.models import User
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
logger = getLogger(__name__) logger = getLogger(__name__)
_PUBLIC_SESSION_FIELDS = frozenset(
{"state", "access_token", "token_type", "expires_in", "scope"}
)
class SessionDataError(Exception):
"""Raised when session data is invalid or malformed."""
class CSRFTokenError(Exception):
"""Raised when CSRF token verification fails."""
class TransitTokenError(Exception):
"""Raised when a transit token is invalid or expired."""
class SessionExpiredError(Exception):
"""Raised when a session has expired."""
class SessionNotFoundError(Exception):
"""Raised when a session is not found."""
class SuspiciousSessionError(Exception):
"""Raised when session state indicates a possible attack or bug."""
class SessionOwnershipError(Exception):
"""Raised when the claimed session_id does not match the result_token binding."""
class SessionState(str, Enum): class SessionState(str, Enum):
"""Add-on authentication session lifecycle states.""" """Add-on authentication session states."""
PENDING = "pending" PENDING = "pending"
AUTHENTICATED = "authenticated" AUTHENTICATED = "authenticated"
class TransitTokenState(str, Enum):
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
PENDING = "pending"
CONSUMED = "consumed"
class TokenExchangeService: class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange.""" """Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self): def __init__(self):
"""Build the underlying JWT service and validate required settings.""" """Initialize the service with the configured token service."""
if not settings.ADDONS_CSRF_SECRET:
raise ImproperlyConfigured("CSRF Secret is required.")
if not settings.ADDONS_TOKEN_SCOPE:
raise ImproperlyConfigured("Token scope must be defined.")
self._token_service = JwtTokenService( self._token_service = JwtTokenService(
secret_key=settings.ADDONS_TOKEN_SECRET_KEY, secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_TOKEN_ALG, algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_TOKEN_ISSUER, issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_TOKEN_AUDIENCE, audience=settings.ADDONS_JWT_AUDIENCE, # todo - precise
expiration_seconds=settings.ADDONS_TOKEN_TTL, expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_TOKEN_TYPE, token_type=settings.ADDONS_JWT_TOKEN_TYPE,
) )
@staticmethod def _session_cache_key(self, session_id: str) -> str:
def _cache_key(prefix: str, token: str) -> str: """Generate cache key for a session ID."""
"""Build a namespaced cache key: ``addons_{prefix}_{token}``.""" return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
return f"addons_{prefix}_{token}"
@staticmethod def _token_cache_key(self, result_token: str) -> str:
def _derive_csrf_token(session_id: str) -> str: """Wip."""
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET.""" return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}"
return hmac.new(
settings.ADDONS_CSRF_SECRET.encode("utf-8"),
session_id.encode("utf-8"),
hashlib.sha256,
).hexdigest()
@staticmethod
def _validate_session_not_expired(session_data: dict) -> int:
"""Return remaining seconds until expiry, or raise if missing/malformed/expired."""
expires_at_str = session_data.get("expires_at")
if expires_at_str is None:
raise SessionDataError("Invalid session data: missing expiration.")
try:
expires_at = datetime.fromisoformat(expires_at_str)
except ValueError as e:
raise SessionDataError("Invalid session data: malformed expiration.") from e
remaining_seconds = int(
(expires_at - datetime.now(timezone.utc)).total_seconds()
)
if remaining_seconds <= 0:
raise SessionExpiredError("Session expired.")
return remaining_seconds
def _generate_session_id(self) -> str:
"""Generate a high-entropy URL-safe session_id."""
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
def _generate_transit_token(self) -> str:
"""Generate a high-entropy URL-safe transit token."""
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
def init_session(self) -> tuple[str, str, str]: def init_session(self) -> tuple[str, str, str]:
"""Create a new pending session and its transit binding. """Create a new pending authentication session and return its ID."""
Returns: session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
(transit_token, session_id, csrf_token) result_token = secrets.token_urlsafe(32) # separate, never in any UR
"""
session_id = self._generate_session_id()
transit_token = self._generate_transit_token()
csrf_token = self._derive_csrf_token(session_id)
expires_at = ( expires_at = datetime.now(timezone.utc) + timedelta(
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL) seconds=settings.ADDONS_SESSION_TIMEOUT
).isoformat() )
session_data = { session_data = {
"state": SessionState.PENDING, "state": SessionState.PENDING,
"expires_at": expires_at, "expires_at": expires_at.isoformat(),
"transit_token": transit_token,
} }
# Store the session itself
cache.set( cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id), self._session_cache_key(session_id),
session_data, session_data,
settings.ADDONS_SESSION_TTL, timeout=settings.ADDONS_SESSION_TIMEOUT,
) )
transit_token_data = { # Store the token → session_id binding (same TTL)
"session_id": session_id,
"state": TransitTokenState.PENDING,
}
cache.set( cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token), self._token_cache_key(result_token),
transit_token_data, session_id,
settings.ADDONS_TRANSIT_TOKEN_TTL, timeout=settings.ADDONS_SESSION_TIMEOUT,
) )
return transit_token, session_id, csrf_token # Transit token → session_id, very short TTL, one-time use
transit_token = secrets.token_urlsafe(32)
def verify_csrf(self, session_id: str, submitted_csrf: str) -> None:
"""Constant-time verify submitted_csrf against HMAC(session_id). Raise on mismatch."""
expected_csrf = self._derive_csrf_token(session_id)
if not hmac.compare_digest(expected_csrf, submitted_csrf):
raise CSRFTokenError("Invalid CSRF token.")
def consume_transit_token(self, transit_token: str) -> str:
"""Mark transit token consumed and return its session_id.
A replay (second consume) evicts the session as a security cleanup and raises.
Raises:
TransitTokenError: If token is unknown, expired, or already consumed.
"""
cache_key = self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
transit_token_data = cache.get(cache_key)
if transit_token_data is None:
# Indistinguishable from here: either the token was never issued (attacker
# probing or client bug) or it was issued but expired before consumption.
logger.warning(
"Transit token not found in cache (unknown or expired).",
)
raise TransitTokenError("Invalid or expired transit token.")
state = transit_token_data.get("state", None)
session_id = transit_token_data.get("session_id", None)
if not session_id:
logger.warning("Transit token data missing session_id.")
raise TransitTokenError("Invalid transit token.")
if state == TransitTokenState.CONSUMED:
logger.warning(
"Replay on session %s",
session_id,
)
# Security cleanup: a replay attempt means the transit token leaked
# (or an attacker is probing). Evict the session so the authenticated
# tokens — if they exist — can no longer be polled.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise TransitTokenError("Transit token already consumed.")
new_transit_token_data = {
"state": TransitTokenState.CONSUMED,
"session_id": session_id,
}
cache.set( cache.set(
cache_key, f"addon_transit_{transit_token}",
new_transit_token_data, session_id,
settings.ADDONS_SESSION_TTL, timeout=120
) )
return session_id print('$$ init transit_token')
print(transit_token)
@staticmethod return session_id, result_token, transit_token
def is_session_pending(session_data: dict) -> bool:
"""Return True if the public session dict is still in the pending state."""
return session_data.get("state") == SessionState.PENDING
def _get_session_data(self, session_id: str) -> dict:
"""Fetch raw session data from cache, or raise SessionNotFoundError."""
if not session_id:
raise SessionNotFoundError("Session not found.")
data = cache.get(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
if data is None:
raise SessionNotFoundError("Session not found.")
return data
# todo - wip
def get_session(self, session_id: str) -> dict: def get_session(self, session_id: str) -> dict:
"""Return the public session view; evict the session on authenticated read. """Retrieve session data and clear it if authenticated."""
Raises: return self._get_and_maybe_clear(session_id)
SessionNotFoundError: If session is not found.
SessionDataError: If session data is missing the state field. def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict:
"""Resolve result_token → session_id → session data.
Verifies that the claimed_session_id matches the token binding,
proving the caller initiated this session (ownership check).
Clears the session once authenticated (one-time read).
""" """
session_id = cache.get(self._token_cache_key(result_token))
if not session_id:
return {}
# raises if session is not found print("$$$ session_id")
session_data = self._get_session_data(session_id) print(session_id)
if "state" not in session_data: print("$$$ claimed_session_id")
raise SessionDataError("Invalid session data: missing state field.") print(claimed_session_id)
# One-time read: clear both bindings for authenticated sessions if not secrets.compare_digest(session_id, claimed_session_id):
if session_data["state"] == SessionState.AUTHENTICATED: raise SessionOwnershipError("Session ID does not match token binding.")
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
# Return public fields only return self._get_and_maybe_clear(session_id)
return {k: v for k, v in session_data.items() if k in _PUBLIC_SESSION_FIELDS}
def _validate_transit_token_state(self, session_data: dict) -> None: def _get_and_maybe_clear(self, session_id: str) -> dict:
"""Assert the session's transit token exists in cache and is in CONSUMED state. """Wip."""
Raises: cache_key = self._session_cache_key(session_id)
SessionDataError: session_data is missing the transit_token field. data = cache.get(cache_key)
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
"""
transit_token = session_data.get("transit_token", None) if not data:
if transit_token is None: return {}
raise SessionDataError("Invalid session data: missing transit_token field.")
transit_token_data = cache.get( if data.get("state") == SessionState.AUTHENTICATED:
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token) # One-time read: clear both the session and the token binding
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, result_token: str | None = None) -> None:
"""Wip."""
cache.delete(self._session_cache_key(session_id))
if result_token:
cache.delete(self._token_cache_key(result_token))
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._session_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 transit_token_data is None: if remaining_seconds <= 0:
logger.warning("Transit token missing when setting access token.") self.clear_session(session_id)
raise SuspiciousSessionError("Transit token not found.") raise SuspiciousOperation("Session expired.")
if transit_token_data.get("state") != TransitTokenState.CONSUMED: if existing_data.get("state") != SessionState.PENDING:
logger.warning("Access token requested without completing transit flow.") self.clear_session(session_id)
raise SuspiciousSessionError("Transit token not consumed.") raise SuspiciousOperation("Access token already set.")
def set_access_token(self, user: User, session_id: str) -> None:
"""Authenticate a pending session by minting a JWT and storing it on the session.
Non-pending sessions are evicted as a security cleanup before raising.
Raises:
SessionNotFoundError: If session doesn't exist.
SessionDataError: If session data is malformed.
SessionExpiredError: If session has expired.
SuspiciousSessionError: If session is not pending or transit wasn't consumed.
"""
# raises if session is not found
session_data = self._get_session_data(session_id)
if session_data.get("state") != SessionState.PENDING:
logger.warning(
"Session's state is not pending. Suspicious.",
)
# Security cleanup: evict the session so any cached tokens cannot be polled.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise SuspiciousSessionError("Session is not in pending state.")
# raises if transit_token is invalid
try:
self._validate_transit_token_state(session_data)
except SuspiciousSessionError:
# Security cleanup: evict the session.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise
# raises if session is expired
remaining_seconds = self._validate_session_not_expired(session_data)
response = self._token_service.generate_jwt(user, settings.ADDONS_TOKEN_SCOPE)
response = self._token_service.generate_jwt(user, settings.ADDONS_SCOPES)
new_data = { new_data = {
"access_token": response["access_token"], **existing_data,
"token_type": response["token_type"], **response,
"expires_in": response["expires_in"],
"scope": response["scope"],
"expires_at": session_data["expires_at"],
"state": SessionState.AUTHENTICATED, "state": SessionState.AUTHENTICATED,
} }
cache.set( cache.set(cache_key, new_data, timeout=remaining_seconds)
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
new_data, def token_to_session(self, result_token):
remaining_seconds, """wip."""
) return None
def consume_transit_token(self, transit_token: str) -> str | None:
"""Resolve and immediately delete the transit token (one-time use)."""
key = f"addon_transit_{transit_token}"
session_id = cache.get(key)
if session_id:
cache.delete(key) # consumed — cannot be replayed
return session_id
+62
View File
@@ -0,0 +1,62 @@
"""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."""
transit_token = request.GET.get("transit_token")
if not transit_token:
return render_error(request, _("Transit token is required."), status=400)
session_id = TokenExchangeService().consume_transit_token(transit_token)
if not session_id:
return render_error(request, _("Invalid or expired transit token."), status=404)
# Validate the session is still pending
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")
+56 -172
View File
@@ -5,225 +5,109 @@ from logging import getLogger
from django.conf import settings from django.conf import settings
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from rest_framework import decorators, viewsets
from rest_framework import ( from rest_framework import (
response as drf_response, response as drf_response,
) )
from rest_framework import decorators
from rest_framework import status as drf_status from rest_framework import status as drf_status
from rest_framework import viewsets
from core.addons.service import ( from core.addons.service import TokenExchangeService, SessionOwnershipError
CSRFTokenError,
SessionDataError,
SessionExpiredError,
SessionNotFoundError,
SuspiciousSessionError,
TokenExchangeService,
TransitTokenError,
)
from core.api.feature_flag import FeatureFlag
from core.api.permissions import IsAuthenticated
logger = getLogger(__name__) logger = getLogger(__name__)
class SessionViewSet(viewsets.ViewSet): class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange. """ViewSet for managing add-on authentication sessions via token exchange."""
Implements a three-step flow that lets a third-party add-on (running in an
embedded iframe) obtain an access token without exposing it to client-side
JavaScript:
1. /init: the add-on opens a session and receives a short-lived transit
token (used to bootstrap the OAuth-style exchange in a dialog) and a
CSRF token. The opaque session id is stored in an HttpOnly, Secure,
SameSite=None cookie so it can accompany cross-origin polls.
2. /poll: the add-on polls until the session transitions from pending to
authenticated. On the terminal read, the session payload (access
token, token type, expiry, etc.) is returned, the session is evicted
server-side, and the session cookie is cleared so the tokens can be
retrieved exactly once.
3. /exchange: called from the post-login callback page on our own domain,
after the user has authenticated in a dialog opened by the addon. The
transit token (carried client-side via postMessage + sessionStorage)
is redeemed here for the authenticated user's access token, which is
stored server-side against the session. Requires an authenticated
user — that user is whose access token gets bound to the session.
/init and /poll authenticate the caller through the session cookie +
CSRF token pair alone — no user login is required, since the whole point
of the flow is to bootstrap one. /exchange, by contrast, requires an
authenticated user and does not use the addonsSid cookie.
"""
authentication_classes = []
permission_classes = []
throttle_classes = [] throttle_classes = []
@decorators.action( def create(self, request):
detail=False, """Create a pending session.
methods=["POST"],
url_path="init",
authentication_classes=[],
permission_classes=[],
)
@FeatureFlag.require("addons")
def init(self, request):
"""Open a new add-on authentication session.
Creates a fresh session server-side and returns the credentials the Returns session_id in the body (client forwards it to the 3rd-party view).
add-on needs to drive the rest of the flow. Sets result_token as an HttpOnly cookie (the only poll credential).
""" """
session_id, result_token, transit_token = TokenExchangeService().init_session()
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
response = drf_response.Response( response = drf_response.Response(
{"transit_token": transit_token, "csrf_token": csrf_token}, {"session_id": session_id, "transit_token": transit_token}, status=drf_status.HTTP_201_CREATED
status=drf_status.HTTP_201_CREATED,
) )
# SameSite=None allows the cookie to be sent on cross-origin requests,
# which is required because the /poll endpoint is called from an iframe
# embedded in a third-party site. Secure=True is mandatory when SameSite=None.
# HttpOnly prevents JS access, so the cookie can only be read by the server.
response.set_cookie( response.set_cookie(
key=settings.ADDONS_SESSION_ID_COOKIE, key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME,
value=session_id, value=result_token,
max_age=settings.ADDONS_SESSION_TTL, max_age=6000,
httponly=True, httponly=True,
secure=True, secure=True,
samesite="None", samesite="None",
) )
return response return response
@decorators.action( @decorators.action(
detail=False, detail=False,
methods=["POST"], methods=["post"],
url_path="poll", url_name="wip",
authentication_classes=[], url_path="wip",
permission_classes=[], permission_classes=[],
authentication_classes=[],
) )
@FeatureFlag.require("addons") def long_poll(self, request):
def poll(self, request): """Long-poll endpoint — only the cookie is accepted, never a session_id.
"""Poll a session for its current state and, if terminal, consume it.
Authenticates the caller using the addonsSid cookie (set by pk is intentionally ignored; the session is resolved from the cookie.
/init) together with the X-CSRFToken header, which must match
the CSRF token issued for that session. The session id alone is not
sufficient — both must be presented and must correspond.
Behavior depends on the session's current state:
- **Pending**: the token exchange has not yet completed. Returns
202 Accepted with `{"state": "pending"}`. The cookie is preserved
so the add-on can keep polling.
- **Authenticated** (or any other terminal state): returns 200 OK
with the session payload (access token, token type, expiry, etc.)
and clears the `addonsSid` cookie. The session is also evicted
server-side on this terminal read, so the tokens can be retrieved
exactly once.
A CSRF mismatch is treated as a `SuspiciousOperation` rather than a
normal 4xx, so it is logged by Django's security middleware and
surfaced as a 400 without leaking which check failed.
""" """
session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE) result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
submitted_csrf = request.headers.get("X-CSRFToken") session_id = request.data.get("session_id")
if not session_id: if not result_token:
return drf_response.Response( return drf_response.Response(
{"detail": "Missing credentials."}, {"detail": "Missing result token."},
status=drf_status.HTTP_401_UNAUTHORIZED, status=drf_status.HTTP_401_UNAUTHORIZED,
) )
if not submitted_csrf: if not session_id:
return drf_response.Response( return drf_response.Response(
{"detail": "Missing CSRF token."}, {"detail": "Missing result session id."},
status=drf_status.HTTP_400_BAD_REQUEST, status=drf_status.HTTP_401_UNAUTHORIZED,
) )
service = TokenExchangeService()
try: try:
service.verify_csrf(session_id, submitted_csrf) data = TokenExchangeService().get_session_by_token(
except CSRFTokenError as e: result_token=result_token,
claimed_session_id=session_id,
)
except SessionOwnershipError as e:
raise SuspiciousOperation(str(e)) from e raise SuspiciousOperation(str(e)) from e
try: if not data:
session = service.get_session(session_id)
except SessionNotFoundError:
return drf_response.Response( return drf_response.Response(
{"detail": "Session not found."}, {"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND, status=drf_status.HTTP_404_NOT_FOUND,
) )
except SessionDataError:
if data.get("state") == "pending":
return drf_response.Response( return drf_response.Response(
{"detail": "Invalid or expired session."}, {"state": "pending"},
status=drf_status.HTTP_400_BAD_REQUEST, status=drf_status.HTTP_202_ACCEPTED,
) )
if service.is_session_pending(session): return drf_response.Response(data, status=drf_status.HTTP_200_OK)
return drf_response.Response(
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
)
response = drf_response.Response(session, status=drf_status.HTTP_200_OK) def destroy(self, request, pk=None):
response.delete_cookie( """Explicit session teardown, resolves via cookie, not pk."""
key=settings.ADDONS_SESSION_ID_COOKIE,
samesite="None", result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
) if not result_token:
return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
# We need the session_id to clear both keys — resolve it first
session_id = TokenExchangeService().token_to_session(result_token)
if session_id:
TokenExchangeService().clear_session(session_id, result_token)
response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
return response return response
@decorators.action(
detail=False,
methods=["POST"],
url_path="exchange",
permission_classes=[IsAuthenticated],
)
@FeatureFlag.require("addons")
def exchange(self, request):
"""Redeem a transit token for an access token bound to the current user.
Called from the post-OIDC callback page on our own domain. The transit
token was issued by /init, passed to the authentication dialog via
postMessage, stashed in sessionStorage, and read back by this page
after login completes.
The authenticated user (request.user) is whose access token gets stored
against the session. On success, the addon's next /poll will transition
from pending to authenticated and receive the token payload.
Transit tokens are single-use: a replayed token is rejected with 400.
"""
transit_token = request.data.get("transit_token")
if not transit_token:
return drf_response.Response(
{"detail": "Missing transit_token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
service = TokenExchangeService()
try:
session_id = service.consume_transit_token(transit_token)
except TransitTokenError:
return drf_response.Response(
{"detail": "Invalid or expired transit token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
try:
service.set_access_token(request.user, session_id)
except SessionNotFoundError:
return drf_response.Response(
{"detail": "Session not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
except (SessionDataError, SessionExpiredError, SuspiciousSessionError):
return drf_response.Response(
{"detail": "Invalid or expired session."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)
+3 -211
View File
@@ -3,55 +3,17 @@
from django import forms from django import forms
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin from django.contrib.auth import admin as auth_admin
from django.db import transaction
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.recording.event import notification from core.recording.event import notification
from . import models from . import models
from .tasks.file import process_file_deletion
from .utils import generate_download_s3_url
def hard_delete_file(file):
"""Hard delete a file, soft deleting it first when needed."""
if file.deleted_at is None:
file.soft_delete()
file.hard_delete()
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
class FileInlineFormSet(forms.BaseInlineFormSet):
"""Inline formset overriding delete behavior for files."""
def delete_existing(self, obj, commit=True):
"""Hard delete files instead of calling model.delete()."""
hard_delete_file(obj)
class FileInline(admin.TabularInline):
"""Inline class for the File model."""
model = models.File
formset = FileInlineFormSet
fk_name = "creator"
extra = 0
fields = ("id", "title", "type", "upload_state", "created_at")
readonly_fields = ("id", "created_at", "upload_state", "type")
show_change_link = True
def get_queryset(self, request):
"""Hide hard deleted files in the inline."""
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
@admin.register(models.User) @admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin): class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model""" """Admin class for the User model"""
inlines = (FileInline,)
fieldsets = ( fieldsets = (
( (
None, None,
@@ -135,136 +97,6 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name") search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.File)
class FileAdmin(admin.ModelAdmin):
"""Admin class for the File model."""
list_display = (
"id",
"title",
"type",
"creator",
"upload_state",
"deleted_at",
"hard_deleted_at",
"created_at",
"updated_at",
)
list_filter = (
"type",
"upload_state",
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
)
search_fields = (
"id",
"title",
"filename",
"mimetype",
"description",
"creator__email",
"creator__admin_email",
"creator__full_name",
)
ordering = ("-created_at",)
readonly_fields = (
"id",
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
"description",
"malware_detection_info",
"is_ready",
"preview_url",
"extension",
"key_base",
"file_key",
"upload_state",
"type",
"mimetype",
"size",
)
autocomplete_fields = ("creator",)
fieldsets = (
(
None,
{
"fields": (
"id",
"title",
"type",
"creator",
"filename",
"upload_state",
)
},
),
(
_("Content"),
{
"fields": (
"mimetype",
"size",
"description",
"malware_detection_info",
)
},
),
(
_("Deletion"),
{
"fields": (
"deleted_at",
"hard_deleted_at",
)
},
),
(
_("Derived info"),
{
"fields": (
"is_ready",
"extension",
"key_base",
"file_key",
"preview_url",
)
},
),
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
)
@admin.display(description=_("File preview"))
def preview_url(self, obj):
"""Return a clickable preview URL for the file."""
if not obj.is_ready:
return "-"
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
return format_html(
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
)
def get_queryset(self, request):
"""Hide hard deleted files in admin listing and lookups."""
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
def delete_model(self, request, obj):
"""Hard delete instead of calling model.delete()."""
hard_delete_file(obj)
def delete_queryset(self, request, queryset):
"""Hard delete all selected files."""
for file in queryset:
hard_delete_file(file)
def has_add_permission(self, request):
return False
class ResourceAccessInline(admin.TabularInline): class ResourceAccessInline(admin.TabularInline):
"""Admin class for the room user access model""" """Admin class for the room user access model"""
@@ -365,51 +197,12 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
) )
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
def mark_as_failed_to_stop(modeladmin, request, queryset):
"""Force selected recordings status to failed_to_stop."""
eligible_statuses = [
models.RecordingStatusChoices.ACTIVE,
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.STOPPED,
]
eligible = queryset.filter(status__in=eligible_statuses)
skipped = queryset.exclude(status__in=eligible_statuses).count()
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
if updated > 0:
modeladmin.message_user(
request,
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
% {"count": updated},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s recording(s) with an ineligible status.")
% {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording) @admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin): class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration.""" """Recording admin interface declaration."""
inlines = (RecordingAccessInline,) inlines = (RecordingAccessInline,)
search_fields = [ search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
list_display = ( list_display = (
"id", "id",
"status", "status",
@@ -431,7 +224,7 @@ class RecordingAdmin(admin.ModelAdmin):
"updated_at", "updated_at",
"worker_id", "worker_id",
) )
actions = [resend_notification, mark_as_failed_to_stop] actions = [resend_notification]
def get_queryset(self, request): def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries.""" """Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -483,7 +276,7 @@ class ApplicationAdmin(admin.ModelAdmin):
form = ApplicationAdminForm form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active") list_display = ("id", "name", "client_id", "get_scopes_display")
fields = [ fields = [
"name", "name",
"id", "id",
@@ -492,7 +285,6 @@ class ApplicationAdmin(admin.ModelAdmin):
"scopes", "scopes",
"client_id", "client_id",
"client_secret", "client_secret",
"is_active",
] ]
readonly_fields = ["id", "created_at", "updated_at"] readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline] inlines = [ApplicationDomainInline]

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