Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaa3a2a284 | |||
| cdc5add2f3 | |||
| 8793226427 | |||
| 357a989b9d | |||
| b8f3c0aa59 | |||
| 37a3d6e7ed | |||
| afa9d36329 | |||
| 3d81110f56 | |||
| 5a508aeb31 | |||
| f9ca489b2f | |||
| 47ec78973b | |||
| c239a7f2e5 | |||
| 87f8d4ca7e | |||
| 45b939da5c | |||
| 5a6654b3a8 | |||
| afaaec4be1 | |||
| 184e957565 | |||
| 323a998721 | |||
| 2b50dd3158 | |||
| 852789cec6 | |||
| f38dec6a93 | |||
| d8548ec9bb | |||
| 775c8ac073 | |||
| 90ef4ce025 | |||
| 8b1d200740 | |||
| f6510b7488 | |||
| 640205fd9e | |||
| 3c04d1e9a7 | |||
| bbc3d18309 | |||
| 61877d23bc | |||
| a6d20e2f58 | |||
| 1e21274237 | |||
| 6b566831a2 | |||
| b57e11bf51 | |||
| 2378af9e9b | |||
| 30107882dd | |||
| 4f25a1d983 | |||
| 2c02a26563 | |||
| 3eb694b6dd | |||
| 9b2e7905fc | |||
| e1c6fb10f3 | |||
| 04ceb5d568 | |||
| 1771527c2d | |||
| 63a949e420 | |||
| 69135feba5 | |||
| 57a27de8e7 | |||
| 09bb4daebc | |||
| 3e3d9f31a5 | |||
| 439f7b3746 | |||
| fa6967635d | |||
| 07e172041f | |||
| 8c93d26d48 | |||
| a2c55f2305 | |||
| 2f1d102b44 | |||
| e9634b7610 | |||
| 4697cbc875 | |||
| 8c3fb63724 | |||
| 964b3cd452 | |||
| c7ca5a621f | |||
| 90ebe231ef | |||
| 04f2a9ebdc | |||
| 6a8eb79b41 | |||
| bc35046b3a | |||
| 1612d8b2d4 | |||
| f8937fc0a1 | |||
| 97b5e3e65c | |||
| b917d82f7e | |||
| 82d146cdf5 | |||
| cbfeea0a4e | |||
| a695758da4 | |||
| 4c5b6de8f3 | |||
| cf4e347589 | |||
| fc260b2686 | |||
| cd7799997e | |||
| a2bccf4f4f | |||
| 6830250f2c | |||
| 0c0ce87947 | |||
| 597eba6e8a | |||
| 47dbc271ba | |||
| c3adcc8ff3 | |||
| 07698ddced | |||
| cce0a45fbc | |||
| da1767c261 | |||
| dd3d47afe6 | |||
| ac2eddc10f | |||
| 35863ec3b6 | |||
| 3d125e940f | |||
| 6f38d60a27 | |||
| 012857f8c6 | |||
| e5a804f748 | |||
| 6bb8084aa0 | |||
| 4548f69de8 | |||
| 1a3a92f901 | |||
| 181b97b310 | |||
| bb816eb2e5 | |||
| 6ee89b201e | |||
| ecb710688d | |||
| 5e1e05b001 | |||
| 211e97edfa | |||
| c4fc46727c | |||
| 28acbb5459 | |||
| 5a81e2b92c | |||
| df24aaab71 | |||
| 3b474ba1c0 | |||
| fc4b6d679a | |||
| 73dd684c8d | |||
| 8507cdd2b6 |
@@ -322,6 +322,11 @@ jobs:
|
||||
- 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:
|
||||
|
||||
@@ -8,6 +8,46 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(summary) change tasks endpoint signature
|
||||
|
||||
### 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
|
||||
|
||||
## [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
|
||||
@@ -26,6 +66,7 @@ and this project adheres to
|
||||
- ✨(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
|
||||
|
||||
|
||||
@@ -133,9 +133,18 @@ run-summary: ## start only the summary application and all needed services
|
||||
.PHONY: run-summary
|
||||
|
||||
run-agents: ## start the multi-user-transcriber agent
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
||||
@$(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
|
||||
.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: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
|
||||
@@ -34,10 +34,11 @@ docker_build(
|
||||
'localhost:5001/meet-frontend-dinum:latest',
|
||||
context='..',
|
||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
only=['./src/frontend', './src/addons', './docker', './.dockerignore'],
|
||||
target = 'frontend-production',
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
sync('../src/addons', '/home/addons'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend-dinum')
|
||||
@@ -108,7 +109,7 @@ k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'liv
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
|
||||
@@ -101,6 +101,12 @@ update_npm_version "mail"
|
||||
# Update backend pyproject.toml
|
||||
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_python_version "summary"
|
||||
|
||||
@@ -149,6 +155,7 @@ echo " - src/frontend/package.json"
|
||||
echo " - src/sdk/package.json"
|
||||
echo " - src/mail/package.json"
|
||||
echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - CHANGELOG.md"
|
||||
|
||||
@@ -246,6 +246,29 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
- LIVEKIT_API_KEY=devkey
|
||||
- LIVEKIT_API_SECRET=secret
|
||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
||||
- AWS_S3_ACCESS_KEY_ID=meet
|
||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./src/agents
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
|
||||
@@ -38,6 +38,21 @@ COPY ./docker/dinum-frontend/assets/ \
|
||||
COPY ./docker/dinum-frontend/fonts/ \
|
||||
./dist/assets/fonts/
|
||||
|
||||
# ---- Addons builder image ----
|
||||
FROM node:20-alpine AS addons-builder
|
||||
|
||||
WORKDIR /home/addons/outlook
|
||||
|
||||
COPY ./src/addons/outlook/package.json ./package.json
|
||||
COPY ./src/addons/outlook/package-lock.json ./package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY ./src/addons/outlook/ .
|
||||
|
||||
RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
|
||||
@@ -60,7 +75,11 @@ COPY --from=meet-builder \
|
||||
/home/frontend/dist \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||
COPY --from=addons-builder \
|
||||
/home/addons/outlook/dist \
|
||||
/usr/share/nginx/html/addons/outlook
|
||||
|
||||
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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 "upgrade-insecure-requests; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
||||
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;
|
||||
}
|
||||
@@ -100,6 +100,13 @@ sequenceDiagram
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **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
|
||||
@@ -141,3 +148,54 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -68,9 +68,24 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
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
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": [
|
||||
"office-addins"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:office-addins/recommended"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 396 B |
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": false
|
||||
}
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?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>__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="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.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="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.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>
|
||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
|
||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
|
||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
|
||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "office-addin-taskpane-js",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/suitenumerique/meet.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"config": {
|
||||
"app_to_debug": "outlook",
|
||||
"app_type_to_debug": "desktop",
|
||||
"dev_server_port": 3000
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack --mode development",
|
||||
"dev-server": "webpack serve --mode development",
|
||||
"lint": "office-addin-lint check",
|
||||
"lint:fix": "office-addin-lint fix",
|
||||
"prettier": "office-addin-lint prettier",
|
||||
"signin": "office-addin-dev-settings m365-account login",
|
||||
"signout": "office-addin-dev-settings m365-account logout",
|
||||
"start": "office-addin-debugging start manifest.xml",
|
||||
"stop": "office-addin-debugging stop manifest.xml",
|
||||
"validate": "office-addin-manifest validate manifest.xml",
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.36.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"office-addin-cli": "^2.0.3",
|
||||
"office-addin-debugging": "^6.0.3",
|
||||
"office-addin-dev-certs": "^2.0.3",
|
||||
"office-addin-lint": "^3.0.3",
|
||||
"office-addin-manifest": "^2.0.3",
|
||||
"office-addin-prettier-config": "^2.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.2.1"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title data-app-name></title>
|
||||
<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="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
/* global Office */
|
||||
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");
|
||||
|
||||
Office.onReady(function (info) {
|
||||
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) {
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de lecture : ${getResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const newBody = getResult.value + message;
|
||||
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur d'insertion : ${setResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
notify("Lien de réunion inséré !");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de localisation : ${locationResult.error.message}`);
|
||||
} else {
|
||||
notify("Lien de réunion inséré !");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.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("Connexion expirée, veuillez réessayer.");
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
notify("Une erreur est survenue, veuillez ré-essayer");
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
openTransitDialog(data.transit_token, {
|
||||
onCancel: () => {
|
||||
stopPolling();
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
stopPolling();
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(`Erreur : ${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);
|
||||
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
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";
|
||||
|
||||
module.exports = {
|
||||
BASE_URL,
|
||||
APP_NAME,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
const { APP_NAME } = require("./index");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// todo - escape html / link
|
||||
function buildMeetingMessage(data) {
|
||||
if (!data?.url) {
|
||||
throw new Error("buildMeetingMessage: missing url in data");
|
||||
}
|
||||
|
||||
const url = data.url;
|
||||
const phone = _formatPhone(data.telephony?.phone_number);
|
||||
const pin = _formatPin(data.telephony?.pin_code);
|
||||
|
||||
const telephonyBlock =
|
||||
phone && pin
|
||||
? `
|
||||
|
||||
Ou appelez (audio uniquement)
|
||||
(FR) ${phone}
|
||||
Code : ${pin}`
|
||||
: "";
|
||||
|
||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
||||
────────────────────────────────────────
|
||||
Rejoindre la réunion ${APP_NAME}
|
||||
|
||||
<a href="${url}">${url}</a>${telephonyBlock}
|
||||
────────────────────────────────────────</pre>`;
|
||||
|
||||
return { url, message };
|
||||
}
|
||||
|
||||
module.exports = { buildMeetingMessage };
|
||||
@@ -0,0 +1,47 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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 };
|
||||
@@ -0,0 +1,15 @@
|
||||
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 };
|
||||
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!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">Veuillez charger le complément.</div>
|
||||
<div class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<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>
|
||||
@@ -0,0 +1,20 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { exchangeSession } = require("../common/api");
|
||||
const { consume } = require("../common/transitToken");
|
||||
|
||||
applyAppName();
|
||||
|
||||
const transitToken = consume();
|
||||
|
||||
if (!transitToken) {
|
||||
console.error("Transit token not found in sessionStorage");
|
||||
window.close();
|
||||
} else {
|
||||
exchangeSession(transitToken)
|
||||
.catch((e) => {
|
||||
console.error(`Error occured: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!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">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 <span data-app-name></span> à vos événements Outlook.</span>
|
||||
</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="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
||||
>
|
||||
Qu’est-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 <span data-app-name></span></button>
|
||||
<button id="btn-disconnect">Se déconnecter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="version-tag">
|
||||
<span class="version-badge">alpha</span>
|
||||
<span class="version-number">0.0.1</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
const { APP_NAME } = 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");
|
||||
|
||||
// todo - support loading view while polling
|
||||
// todo - support error view
|
||||
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 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"));
|
||||
}
|
||||
|
||||
function _setButtonLoading() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Génération...";
|
||||
}
|
||||
|
||||
function _setButtonIdle() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = false;
|
||||
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
|
||||
}
|
||||
|
||||
function generateMeetingLink() {
|
||||
const session = loadSession();
|
||||
if (!session?.access_token) {
|
||||
console.error("Session introuvable. Veuillez vous reconnecter.");
|
||||
showView("unauth");
|
||||
return;
|
||||
}
|
||||
|
||||
_setButtonLoading();
|
||||
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(getResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
item.body.setAsync(
|
||||
getResult.value + message,
|
||||
{ coercionType: Office.CoercionType.Html },
|
||||
(setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(setResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── If calendar event, also set location ──────────────
|
||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||
item.location.setAsync(url, () => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
_setButtonIdle();
|
||||
});
|
||||
}
|
||||
|
||||
Office.onReady((info) => {
|
||||
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;
|
||||
|
||||
const session = loadSession();
|
||||
if (session?.state === "authenticated" && session?.access_token) {
|
||||
showView("auth");
|
||||
} else {
|
||||
showView("unauth");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<!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">Veuillez charger le complément.</div>
|
||||
<div
|
||||
class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<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>
|
||||
@@ -0,0 +1,53 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { URLS } = require("../common/urls");
|
||||
const { save } = require("../common/transitToken");
|
||||
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
||||
|
||||
// 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(function (info) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const devCerts = require("office-addin-dev-certs");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
||||
|
||||
async function getHttpsOptions() {
|
||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||
}
|
||||
|
||||
module.exports = async (env, options) => {
|
||||
const config = {
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
||||
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
||||
commands: "./src/commands/commands.js",
|
||||
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
|
||||
success: ["./src/success/success.js", "./src/success/success.html"],
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".html", ".js"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
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)$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name][ext][query]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "taskpane.html",
|
||||
template: "./src/taskpane/taskpane.html",
|
||||
chunks: ["polyfill", "taskpane"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "assets/*",
|
||||
to: "assets/[name][ext][query]",
|
||||
}
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "commands.html",
|
||||
template: "./src/commands/commands.html",
|
||||
chunks: ["polyfill", "commands"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
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(),
|
||||
],
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
server: {
|
||||
type: "https",
|
||||
options:
|
||||
env.WEBPACK_BUILD || options.https !== undefined
|
||||
? options.https
|
||||
: await getHttpsOptions(),
|
||||
},
|
||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim AS base
|
||||
FROM python:3.13.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -15,19 +15,30 @@ COPY pyproject.toml .
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
FROM base AS development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir ".[dev]"
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "metadata_collector.py", "dev"]
|
||||
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Un-privileged user running the application
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY ./*.py /app/
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Storage parsers specific exceptions."""
|
||||
|
||||
|
||||
class MissingConfigError(Exception):
|
||||
"""Raised when a variable is not set in configuration."""
|
||||
@@ -0,0 +1,382 @@
|
||||
"""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,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
|
||||
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."""
|
||||
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(lambda _: self._tasks.remove(task))
|
||||
|
||||
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)
|
||||
|
||||
def on_close_done(_):
|
||||
self._tasks.discard(task)
|
||||
logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
)
|
||||
|
||||
task.add_done_callback(on_close_done)
|
||||
|
||||
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,
|
||||
input_options=RoomInputOptions(
|
||||
audio_enabled=True,
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
audio_enabled=False,
|
||||
transcription_enabled=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.drain()
|
||||
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."""
|
||||
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__":
|
||||
cli.run_app(server)
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
@@ -9,7 +9,8 @@ dependencies = [
|
||||
"livekit-plugins-silero==1.4.5",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.5"
|
||||
"protobuf==6.33.5",
|
||||
"minio==7.2.15"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -17,6 +18,9 @@ dev = [
|
||||
"ruff==0.15.6",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core add-ons module."""
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from core.models import User
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
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 SessionState(str, Enum):
|
||||
"""Add-on authentication session lifecycle states."""
|
||||
|
||||
PENDING = "pending"
|
||||
AUTHENTICATED = "authenticated"
|
||||
|
||||
|
||||
class TransitTokenState(str, Enum):
|
||||
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
|
||||
|
||||
PENDING = "pending"
|
||||
CONSUMED = "consumed"
|
||||
|
||||
|
||||
class TokenExchangeService:
|
||||
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
||||
|
||||
def __init__(self):
|
||||
"""Build the underlying JWT service and validate required settings."""
|
||||
|
||||
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(
|
||||
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
issuer=settings.ADDONS_TOKEN_ISSUER,
|
||||
audience=settings.ADDONS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.ADDONS_TOKEN_TTL,
|
||||
token_type=settings.ADDONS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(prefix: str, token: str) -> str:
|
||||
"""Build a namespaced cache key: ``addons_{prefix}_{token}``."""
|
||||
return f"addons_{prefix}_{token}"
|
||||
|
||||
@staticmethod
|
||||
def _derive_csrf_token(session_id: str) -> str:
|
||||
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET."""
|
||||
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]:
|
||||
"""Create a new pending session and its transit binding.
|
||||
|
||||
Returns:
|
||||
(transit_token, session_id, csrf_token)
|
||||
"""
|
||||
session_id = self._generate_session_id()
|
||||
transit_token = self._generate_transit_token()
|
||||
csrf_token = self._derive_csrf_token(session_id)
|
||||
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL)
|
||||
).isoformat()
|
||||
|
||||
session_data = {
|
||||
"state": SessionState.PENDING,
|
||||
"expires_at": expires_at,
|
||||
"transit_token": transit_token,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
||||
session_data,
|
||||
settings.ADDONS_SESSION_TTL,
|
||||
)
|
||||
|
||||
transit_token_data = {
|
||||
"session_id": session_id,
|
||||
"state": TransitTokenState.PENDING,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token),
|
||||
transit_token_data,
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL,
|
||||
)
|
||||
|
||||
return transit_token, session_id, csrf_token
|
||||
|
||||
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_key,
|
||||
new_transit_token_data,
|
||||
settings.ADDONS_SESSION_TTL,
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
def get_session(self, session_id: str) -> dict:
|
||||
"""Return the public session view; evict the session on authenticated read.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If session is not found.
|
||||
SessionDataError: If session data is missing the state field.
|
||||
"""
|
||||
|
||||
# raises if session is not found
|
||||
session_data = self._get_session_data(session_id)
|
||||
|
||||
if "state" not in session_data:
|
||||
raise SessionDataError("Invalid session data: missing state field.")
|
||||
|
||||
# One-time read: clear both bindings for authenticated sessions
|
||||
if session_data["state"] == SessionState.AUTHENTICATED:
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
|
||||
# Return public fields only
|
||||
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:
|
||||
"""Assert the session's transit token exists in cache and is in CONSUMED state.
|
||||
|
||||
Raises:
|
||||
SessionDataError: session_data is missing the transit_token field.
|
||||
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
|
||||
"""
|
||||
|
||||
transit_token = session_data.get("transit_token", None)
|
||||
if transit_token is None:
|
||||
raise SessionDataError("Invalid session data: missing transit_token field.")
|
||||
|
||||
transit_token_data = cache.get(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
||||
)
|
||||
|
||||
if transit_token_data is None:
|
||||
logger.warning("Transit token missing when setting access token.")
|
||||
raise SuspiciousSessionError("Transit token not found.")
|
||||
|
||||
if transit_token_data.get("state") != TransitTokenState.CONSUMED:
|
||||
logger.warning("Access token requested without completing transit flow.")
|
||||
raise SuspiciousSessionError("Transit token not consumed.")
|
||||
|
||||
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)
|
||||
|
||||
new_data = {
|
||||
"access_token": response["access_token"],
|
||||
"token_type": response["token_type"],
|
||||
"expires_in": response["expires_in"],
|
||||
"scope": response["scope"],
|
||||
"expires_at": session_data["expires_at"],
|
||||
"state": SessionState.AUTHENTICATED,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
||||
new_data,
|
||||
remaining_seconds,
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Add-ons API endpoints"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from rest_framework import decorators, viewsets
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import status as drf_status
|
||||
|
||||
from core.addons.service import (
|
||||
CSRFTokenError,
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
TransitTokenError,
|
||||
)
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.api.permissions import IsAuthenticated
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SessionViewSet(viewsets.ViewSet):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
throttle_classes = []
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
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
|
||||
add-on needs to drive the rest of the flow.
|
||||
"""
|
||||
|
||||
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
|
||||
|
||||
response = drf_response.Response(
|
||||
{"transit_token": transit_token, "csrf_token": csrf_token},
|
||||
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(
|
||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
||||
value=session_id,
|
||||
max_age=settings.ADDONS_SESSION_TTL,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="None",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="poll",
|
||||
authentication_classes=[],
|
||||
permission_classes=[],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def poll(self, request):
|
||||
"""Poll a session for its current state and, if terminal, consume it.
|
||||
|
||||
Authenticates the caller using the addonsSid cookie (set by
|
||||
/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)
|
||||
submitted_csrf = request.headers.get("X-CSRFToken")
|
||||
|
||||
if not session_id:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing credentials."},
|
||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not submitted_csrf:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing CSRF token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = TokenExchangeService()
|
||||
|
||||
try:
|
||||
service.verify_csrf(session_id, submitted_csrf)
|
||||
except CSRFTokenError as e:
|
||||
raise SuspiciousOperation(str(e)) from e
|
||||
|
||||
try:
|
||||
session = service.get_session(session_id)
|
||||
except SessionNotFoundError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Session not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except SessionDataError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired session."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if service.is_session_pending(session):
|
||||
return drf_response.Response(
|
||||
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
response = drf_response.Response(session, status=drf_status.HTTP_200_OK)
|
||||
response.delete_cookie(
|
||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
||||
samesite="None",
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -14,6 +14,8 @@ class FeatureFlag:
|
||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
@@ -131,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""Validate room configuration against the RoomConfiguration schema."""
|
||||
if value is None or value == {}:
|
||||
return value
|
||||
try:
|
||||
RoomConfiguration.model_validate(value)
|
||||
except PydanticValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -232,11 +243,14 @@ class RecordingOptions(BaseModel):
|
||||
When `None`, falls back to the application default.
|
||||
original_mode: The original recording mode before any override.
|
||||
Must be one of the valid RecordingModeChoices values when provided.
|
||||
collect_metadata: Whether to collect additional metadata during recording.
|
||||
When `None`, no metadata are collected.
|
||||
|
||||
"""
|
||||
|
||||
language: str | None = None
|
||||
transcribe: bool | None = None
|
||||
collect_metadata: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
@@ -303,6 +317,22 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
RoomConfigurationTrackSource = Literal[
|
||||
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||
]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
"""Validate room configuration structure.
|
||||
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ from logging import getLogger
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -45,6 +47,10 @@ from core.recording.event.exceptions import (
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
@@ -316,16 +322,27 @@ class RoomViewSet(
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
)
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
# pass that check and the DB-level UNIQUE constraint catches the loser.
|
||||
return drf_response.Response(
|
||||
{"error": f"A recording is already in progress for room {room.slug}"},
|
||||
status=drf_status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
@@ -333,11 +350,23 @@ class RoomViewSet(
|
||||
try:
|
||||
worker_manager.start(recording)
|
||||
except RecordingStartError:
|
||||
models.Recording.objects.filter(pk=recording.pk).update(
|
||||
status=models.RecordingStatusChoices.FAILED_TO_START
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status=drf_status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and (
|
||||
recording.options.get("collect_metadata", False)
|
||||
):
|
||||
try:
|
||||
MetadataCollectorService().start(recording)
|
||||
logger.debug("Started MetadataCollectorService")
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to start MetadataCollectorService")
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording successfully started for room {room.slug}"},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
|
||||
@@ -23,7 +23,14 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""Base JWT authentication class."""
|
||||
|
||||
def __init__(
|
||||
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
||||
self,
|
||||
secret_key,
|
||||
algorithm,
|
||||
issuer,
|
||||
audience,
|
||||
expiration_seconds,
|
||||
token_type,
|
||||
is_enabled,
|
||||
):
|
||||
"""Initialize the JWT authentication backend with the given token service configuration.
|
||||
|
||||
@@ -34,10 +41,17 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
audience: Expected token audience identifier
|
||||
expiration_seconds: Token expiration time in seconds
|
||||
token_type: Token type (e.g. Bearer)
|
||||
is_enabled: Whether this authentication backend is active
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.is_enabled = is_enabled
|
||||
self._token_service = None
|
||||
|
||||
if not self.is_enabled:
|
||||
return
|
||||
|
||||
self._token_service = jwt_token.JwtTokenService(
|
||||
secret_key=secret_key,
|
||||
algorithm=algorithm,
|
||||
@@ -54,6 +68,9 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
Tuple of (user, payload) if authentication successful, None otherwise
|
||||
"""
|
||||
|
||||
if not self.is_enabled:
|
||||
return None
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
@@ -186,6 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
is_enabled=settings.APPLICATION_ENABLED,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
@@ -214,6 +232,26 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
|
||||
class AddonsJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for addons API access.
|
||||
|
||||
Validates JWT tokens issued to addons.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication backend with addons JWT settings from Django settings."""
|
||||
|
||||
super().__init__(
|
||||
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
issuer=settings.ADDONS_TOKEN_ISSUER,
|
||||
audience=settings.ADDONS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.ADDONS_TOKEN_TTL,
|
||||
token_type=settings.ADDONS_TOKEN_TYPE,
|
||||
is_enabled=settings.ADDONS_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
class ResourceServerBackend(LaSuiteBackend):
|
||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
from . import authentication, permissions, serializers
|
||||
@@ -36,6 +37,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
"""Generate JWT access token for application delegation.
|
||||
|
||||
@@ -173,6 +175,7 @@ class RoomViewSet(
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
@@ -9,9 +11,12 @@ from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,7 +136,50 @@ class NotificationService:
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
async def _get_recording_timestamps(worker_id):
|
||||
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
|
||||
|
||||
FileInfo.started_at is more accurate than EgressInfo.started_at because
|
||||
it reflects when file recording actually began. The started_at value exposed
|
||||
in the manifest file, as well as in the EgressInfo returned by the API,
|
||||
corresponds to when the egress service received the request, not the moment
|
||||
the egress worker effectively joined the room.
|
||||
|
||||
Returns:
|
||||
Tuple of (started_at, ended_at) datetimes, either may be None.
|
||||
"""
|
||||
|
||||
if not worker_id:
|
||||
return None, None
|
||||
|
||||
custom_configuration = {
|
||||
**settings.LIVEKIT_CONFIGURATION,
|
||||
"timeout": aiohttp.ClientTimeout(total=10),
|
||||
}
|
||||
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
|
||||
try:
|
||||
egress_list = await lkapi.egress.list_egress(
|
||||
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
|
||||
)
|
||||
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
|
||||
logger.exception("Could not fetch egress info for worker %s", worker_id)
|
||||
return None, None
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
if not egress_list.items or not egress_list.items[0].file_results:
|
||||
logger.debug("No file_results for worker %s", worker_id)
|
||||
return None, None
|
||||
|
||||
file_result = egress_list.items[0].file_results[0]
|
||||
|
||||
def _ns_to_utc(ns):
|
||||
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -150,24 +198,35 @@ class NotificationService:
|
||||
.first()
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
else:
|
||||
metadata_filename = None
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"filename": recording.key,
|
||||
"recording_filename": recording.key,
|
||||
"metadata_filename": metadata_filename,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%H:%M"),
|
||||
"owner_timezone": str(owner_access.user.timezone),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": owner_access.user.language,
|
||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Meeting metadata collection service."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from asgiref.sync import async_to_sync, sync_to_async
|
||||
from livekit.protocol.agent_dispatch import (
|
||||
CreateAgentDispatchRequest,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataCollectorException(Exception):
|
||||
"""Generic exception in the metadata collector."""
|
||||
|
||||
|
||||
class MetadataCollectorService:
|
||||
"""Service for dispatching and managing the metadata collector agent."""
|
||||
|
||||
@async_to_sync
|
||||
async def start(self, recording: Recording):
|
||||
"""Explicitly dispatch the metadata collector agent to a room."""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
room_id = str(recording.room.id)
|
||||
|
||||
try:
|
||||
response = await lkapi.agent_dispatch.create_dispatch(
|
||||
CreateAgentDispatchRequest(
|
||||
agent_name=settings.METADATA_COLLECTOR_AGENT_NAME,
|
||||
room=room_id,
|
||||
metadata=str(recording.id),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to create metadata collector agent for room %s", room_id
|
||||
)
|
||||
raise MetadataCollectorException(
|
||||
"Failed to create metadata collector agent"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
dispatch_id = getattr(response, "id", None)
|
||||
|
||||
if not dispatch_id:
|
||||
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
|
||||
raise MetadataCollectorException(
|
||||
f"LiveKit did not return a dispatch_id for room {room_id}"
|
||||
)
|
||||
|
||||
recording.options["metadata_collector_dispatch_id"] = dispatch_id
|
||||
await sync_to_async(recording.save)(update_fields=["options"])
|
||||
|
||||
return dispatch_id
|
||||
|
||||
@async_to_sync
|
||||
async def stop(self, recording: Recording):
|
||||
"""Stop and delete the agent dispatch associated to the room."""
|
||||
|
||||
room_id = str(recording.room.id)
|
||||
dispatch_id = recording.options.get("metadata_collector_dispatch_id")
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
if not dispatch_id:
|
||||
logger.warning(
|
||||
"No metadata collector dispatch ID stored for room %s", room_id
|
||||
)
|
||||
return None
|
||||
|
||||
await lkapi.agent_dispatch.delete_dispatch(
|
||||
dispatch_id=str(dispatch_id), room_name=room_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to stop metadata collector agent dispatch for room %s",
|
||||
room_id,
|
||||
)
|
||||
raise MetadataCollectorException(
|
||||
f"Failed to stop metadata collector agent for room {room_id}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -7,6 +7,7 @@ from logging import getLogger
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -19,7 +20,7 @@ class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording, egress_status):
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
@@ -40,7 +41,7 @@ class RecordingEventsService:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording):
|
||||
def handle_limit_reached(recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Factory, configurations and Protocol to create worker services"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -8,8 +10,17 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from livekit import api as livekit_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
|
||||
# Kept fixed because changing them would shift the goal-post away from the
|
||||
# "safe drop-in replacement for the default preset" contract of this feature.
|
||||
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
|
||||
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
@@ -18,6 +29,7 @@ class WorkerServiceConfig:
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
bucket_args: Optional[dict]
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
@@ -25,6 +37,24 @@ class WorkerServiceConfig:
|
||||
"""Load configuration from Django settings with caching for efficiency."""
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
if settings.RECORDING_ENCODING_ENABLED:
|
||||
# Single source of truth for the EncodingOptions kwargs:
|
||||
# operator-tunable values live in Django settings, codec / frequency
|
||||
# are pinned constants. The services layer only unpacks this dict.
|
||||
encoding_options = {
|
||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||
}
|
||||
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
@@ -36,6 +66,7 @@ class WorkerServiceConfig:
|
||||
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"force_path_style": True,
|
||||
},
|
||||
encoding_options=encoding_options,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,22 @@ class BaseEgressService:
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement this method.")
|
||||
|
||||
def _build_encoding_options(self):
|
||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
||||
|
||||
When None is returned, the caller should omit the `advanced` field so
|
||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
||||
|
||||
The full EncodingOptions kwargs (operator-tunable values + pinned
|
||||
codec / frequency constants) are assembled in `WorkerServiceConfig`,
|
||||
so this method is a thin protobuf adapter.
|
||||
"""
|
||||
opts = self._config.encoding_options
|
||||
if not opts:
|
||||
return None
|
||||
|
||||
return livekit_api.EncodingOptions(**opts)
|
||||
|
||||
|
||||
class VideoCompositeEgressService(BaseEgressService):
|
||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||
@@ -104,9 +120,17 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
s3=self._s3,
|
||||
)
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
|
||||
)
|
||||
request_kwargs = {
|
||||
"room_name": room_name,
|
||||
"file_outputs": [file_output],
|
||||
"layout": "speaker-light",
|
||||
}
|
||||
|
||||
advanced = self._build_encoding_options()
|
||||
if advanced is not None:
|
||||
request_kwargs["advanced"] = advanced
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ from django.conf import settings
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
@@ -158,7 +162,7 @@ class LiveKitEventsService:
|
||||
"""Handle 'egress_ended' event."""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
)
|
||||
except models.Recording.DoesNotExist as err:
|
||||
@@ -174,6 +178,12 @@ class LiveKitEventsService:
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
|
||||
@@ -123,7 +123,7 @@ class LobbyService:
|
||||
|
||||
def request_entry(
|
||||
self,
|
||||
room,
|
||||
room: models.Room,
|
||||
request,
|
||||
username: str,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Tests for add-ons API /sessions/init and /sessions/poll endpoints
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.addons.service import (
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ================================
|
||||
# endpoint /addons/sessions/init/
|
||||
# ================================
|
||||
|
||||
|
||||
def test_init_feature_flag_disabled(client, settings):
|
||||
"""Should return 404 on POST when feature is disabled."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
response = client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_init_only_accepts_post():
|
||||
"""Should return 201 JSON with only transit_token and csrf_token."""
|
||||
|
||||
response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response["Content-Type"] == "application/json"
|
||||
|
||||
response_data = response.json()
|
||||
# session_id must only be delivered via cookie, not in the same channel as csrf_token.
|
||||
assert set(response_data.keys()) == {"transit_token", "csrf_token"}
|
||||
|
||||
transit_token = response_data["transit_token"]
|
||||
|
||||
# URL-safe base64 alphabet: A-Z, a-z, 0-9, -, _
|
||||
assert re.match(r"^[A-Za-z0-9_-]+$", transit_token)
|
||||
|
||||
csrf_token = response_data["csrf_token"]
|
||||
# HMAC-SHA256 → 64-character hex string.
|
||||
assert re.match(r"^[a-f0-9]{64}$", csrf_token)
|
||||
|
||||
assert csrf_token != transit_token
|
||||
|
||||
|
||||
def test_init_rejects_non_post_methods():
|
||||
"""Should return 405 Method Not Allowed on GET."""
|
||||
response = APIClient().get("/api/v1.0/addons/sessions/init/")
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_init_generates_unique_tokens_across_calls():
|
||||
"""Should generate a distinct transit_token and csrf_token for every call."""
|
||||
|
||||
api_client = APIClient()
|
||||
|
||||
tokens = set()
|
||||
csrf_tokens = set()
|
||||
|
||||
for _ in range(5):
|
||||
response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
tokens.add(response.json()["transit_token"])
|
||||
csrf_tokens.add(response.json()["csrf_token"])
|
||||
|
||||
assert len(tokens) == 5
|
||||
assert len(csrf_tokens) == 5
|
||||
|
||||
|
||||
def test_init_cookie_authorizes_subsequent_poll():
|
||||
"""Should issue a session cookie that, with the returned csrf_token, authorizes /poll."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
assert "addonsSid" in init_response.cookies
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 202
|
||||
assert poll_response.json() == {"state": "pending"}
|
||||
|
||||
|
||||
def test_init_session_id_cookie_attributes(settings):
|
||||
"""Should set the session cookie with the security attributes required for iframe embedding."""
|
||||
response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
|
||||
cookies = response.cookies
|
||||
assert list(cookies) == ["addonsSid"] # only this cookie
|
||||
|
||||
cookie = cookies["addonsSid"]
|
||||
assert re.match(r"^[A-Za-z0-9_-]+$", cookie.value), "URL-safe base64 expected"
|
||||
assert cookie["httponly"] is True, (
|
||||
"HttpOnly required — cookie must not be JS-readable"
|
||||
)
|
||||
assert cookie["secure"] is True, (
|
||||
"Secure required — cookie must not travel over HTTP"
|
||||
)
|
||||
assert cookie["samesite"] == "None", (
|
||||
"SameSite=None required for cross-origin iframe"
|
||||
)
|
||||
assert cookie["max-age"] == settings.ADDONS_SESSION_TTL
|
||||
|
||||
|
||||
def test_init_session_id_cookie_respects_configured_name(settings):
|
||||
"""Should name the session cookie according to the ADDONS_SESSION_ID_COOKIE setting."""
|
||||
api_client = APIClient()
|
||||
|
||||
settings.ADDONS_SESSION_ID_COOKIE = "mockSessionSid"
|
||||
|
||||
response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert "mockSessionSid" in response.cookies
|
||||
assert response.cookies.get("mockSessionSid") is not None
|
||||
|
||||
|
||||
# =================================
|
||||
# endpoint /addons/sessions/poll/
|
||||
# =================================
|
||||
|
||||
|
||||
def test_poll_feature_flag_disabled(client, settings):
|
||||
"""Should return 404 on POST when feature is disabled."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
response = client.post("/api/v1.0/addons/sessions/poll/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_poll_rejects_missing_csrf_token():
|
||||
"""Should reject requests that carry the sessionSid cookie but omit the CSRF header."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
# X-CSRF-Token is deliberately omitted
|
||||
poll_response = api_client.post("/api/v1.0/addons/sessions/poll/")
|
||||
|
||||
assert poll_response.status_code == 400
|
||||
assert poll_response.json() == {"detail": "Missing CSRF token."}
|
||||
|
||||
|
||||
def test_poll_missing_cookie():
|
||||
"""Should return 401 when no sessionSid cookie is present."""
|
||||
api_client = APIClient()
|
||||
|
||||
poll_response = api_client.post("/api/v1.0/addons/sessions/poll/")
|
||||
|
||||
assert poll_response.status_code == 401
|
||||
assert poll_response.json() == {"detail": "Missing credentials."}
|
||||
|
||||
|
||||
def test_poll_rejects_invalid_csrf_token():
|
||||
"""Should reject requests carrying an invalid CSRF token."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN="invalid-csrf-token",
|
||||
)
|
||||
|
||||
# SuspiciousOperation translates to 400 via Django's exception middleware.
|
||||
assert poll_response.status_code == 400
|
||||
|
||||
|
||||
@patch(
|
||||
"core.addons.service.TokenExchangeService._get_session_data",
|
||||
side_effect=SessionNotFoundError("Session not found."),
|
||||
)
|
||||
def test_poll_session_not_found(mock_get_session_data):
|
||||
"""Should return 404 when the session is not found."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 404
|
||||
assert poll_response.json() == {"detail": "Session not found."}
|
||||
|
||||
|
||||
@patch(
|
||||
"core.addons.service.TokenExchangeService._get_session_data",
|
||||
side_effect=SessionDataError("Session corrupted."),
|
||||
)
|
||||
def test_poll_session_corrupted(mock_get_session_data):
|
||||
"""Should return 400 when the session is corrupted."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 400
|
||||
assert poll_response.json() == {"detail": "Invalid or expired session."}
|
||||
|
||||
|
||||
def test_poll_session_authenticated():
|
||||
"""Should return tokens and tears down the polling channel when authenticated."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
session_id_cookie = init_response.cookies["addonsSid"]
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
# Simulate Authentication done in the opened dialog
|
||||
service = TokenExchangeService()
|
||||
service.consume_transit_token(transit_token)
|
||||
service.set_access_token(UserFactory(), session_id_cookie.value)
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 200
|
||||
response_data = poll_response.json()
|
||||
access_token = response_data.pop("access_token")
|
||||
assert isinstance(access_token, str) and access_token # non-empty string
|
||||
assert response_data == {
|
||||
"expires_in": 7200,
|
||||
"scope": "rooms:create",
|
||||
"state": "authenticated",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
# Verify the server cleared the addonsSid cookie
|
||||
cleared_cookie = poll_response.cookies["addonsSid"]
|
||||
assert cleared_cookie.value == ""
|
||||
assert cleared_cookie["max-age"] == 0
|
||||
|
||||
# Server cleared the addonsSid cookie; APIClient drops it → no credentials.
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert poll_response.status_code == 401
|
||||
|
||||
# Replay the original addonsSid: session was evicted on terminal read.
|
||||
api_client.cookies["addonsSid"] = session_id_cookie.value
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert poll_response.status_code == 404
|
||||
assert poll_response.json() == {"detail": "Session not found."}
|
||||
|
||||
|
||||
def test_poll_two_clients_do_not_interfere():
|
||||
"""Two clients poll independently; CSRF tokens are bound to their own session."""
|
||||
client_a = APIClient()
|
||||
client_b = APIClient()
|
||||
|
||||
init_a = client_a.post("/api/v1.0/addons/sessions/init/")
|
||||
init_b = client_b.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_a.status_code == 201
|
||||
assert init_b.status_code == 201
|
||||
|
||||
csrf_a = init_a.json()["csrf_token"]
|
||||
csrf_b = init_b.json()["csrf_token"]
|
||||
poll_id_a = init_a.cookies["addonsSid"].value
|
||||
poll_id_b = init_b.cookies["addonsSid"].value
|
||||
|
||||
# Sessions must be distinct.
|
||||
assert csrf_a != csrf_b
|
||||
assert poll_id_a != poll_id_b
|
||||
|
||||
# Each client polls its own session.
|
||||
poll_a = client_a.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_a,
|
||||
)
|
||||
poll_b = client_b.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_b,
|
||||
)
|
||||
assert poll_a.status_code == 202
|
||||
assert poll_b.status_code == 202
|
||||
|
||||
# Cross-use (A's cookie + B's CSRF) must be rejected.
|
||||
cross_response = client_a.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_b,
|
||||
)
|
||||
assert cross_response.status_code == 400
|
||||
|
||||
# A's session transitioning to authenticated must not affect B.
|
||||
with patch(
|
||||
"core.addons.service.TokenExchangeService._get_session_data",
|
||||
return_value={
|
||||
"state": "authenticated",
|
||||
"expires_at": "foo",
|
||||
"access_token": "mock-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 100,
|
||||
},
|
||||
):
|
||||
poll_a = client_a.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_a,
|
||||
)
|
||||
assert poll_a.status_code == 200
|
||||
|
||||
poll_b = client_b.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_b,
|
||||
)
|
||||
assert poll_b.status_code == 202
|
||||
|
||||
|
||||
def test_poll_csrf_attack_does_not_disrupt_legitimate_client():
|
||||
"""CSRF attack using the pollId cookie must fail without burning the session."""
|
||||
legitimate = APIClient()
|
||||
|
||||
init_response = legitimate.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
session_id_value = init_response.cookies["addonsSid"].value
|
||||
|
||||
# Attacker has the cookie (SameSite=None) but not the CSRF token.
|
||||
attacker = APIClient()
|
||||
attacker.cookies["addonsSid"] = session_id_value
|
||||
|
||||
# No CSRF header
|
||||
attack_no_csrf = attacker.post("/api/v1.0/addons/sessions/poll/")
|
||||
assert attack_no_csrf.status_code == 400
|
||||
assert attack_no_csrf.json() == {"detail": "Missing CSRF token."}
|
||||
|
||||
# Fabricated CSRF token
|
||||
attack_bad_csrf = attacker.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN="attacker-guessed-token",
|
||||
)
|
||||
assert attack_bad_csrf.status_code == 400
|
||||
|
||||
# Legitimate client's session is still usable.
|
||||
legitimate_poll = legitimate.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert legitimate_poll.status_code == 202
|
||||
assert legitimate_poll.json() == {"state": "pending"}
|
||||
|
||||
|
||||
# =====================================
|
||||
# endpoint /addons/sessions/exchange/
|
||||
# =====================================
|
||||
|
||||
|
||||
def test_exchange_feature_flag_disabled(settings):
|
||||
"""Should return 404 on POST when feature is disabled."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post("/api/v1.0/addons/sessions/exchange/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_requires_authentication():
|
||||
"""Should return 401 when the caller is not authenticated."""
|
||||
api_client = APIClient()
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": "irrelevant"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_exchange_rejects_missing_transit_token():
|
||||
"""Should return 400 when the request body has no transit_token."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Missing transit_token."}
|
||||
|
||||
|
||||
def test_exchange_rejects_empty_transit_token():
|
||||
"""Should return 400 when transit_token is present but empty."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": ""},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Missing transit_token."}
|
||||
|
||||
|
||||
def test_exchange_rejects_invalid_transit_token():
|
||||
"""Should return 400 when the transit token is unknown or malformed."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": "not-a-real-transit-token"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid or expired transit token."}
|
||||
|
||||
|
||||
def test_exchange_rejects_replayed_transit_token():
|
||||
"""Should return 400 when a transit token is reused after being consumed."""
|
||||
init_client = APIClient()
|
||||
init_response = init_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=UserFactory())
|
||||
|
||||
first = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
assert second.status_code == 400
|
||||
assert second.json() == {"detail": "Invalid or expired transit token."}
|
||||
|
||||
|
||||
def test_exchange_success_enables_poll_to_complete():
|
||||
"""Should bind tokens to the session so the polling completes."""
|
||||
# 1. Taskpane opens a session.
|
||||
taskpane = APIClient()
|
||||
init_response = taskpane.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
# 2. Dialog completes OIDC; post-login page (authenticated, separate
|
||||
# client — no addonsSid cookie) calls /exchange with the transit token.
|
||||
dialog = APIClient()
|
||||
dialog.force_authenticate(user=UserFactory())
|
||||
|
||||
exchange_response = dialog.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
assert exchange_response.status_code == 200
|
||||
assert exchange_response.json() == {"status": "ok"}
|
||||
|
||||
# 3. Taskpane's next poll transitions from pending → authenticated.
|
||||
poll_response = taskpane.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert poll_response.status_code == 200
|
||||
response_data = poll_response.json()
|
||||
assert response_data["state"] == "authenticated"
|
||||
assert response_data["token_type"] == "Bearer"
|
||||
assert isinstance(response_data["access_token"], str)
|
||||
assert response_data["access_token"]
|
||||
|
||||
|
||||
@patch(
|
||||
"core.addons.service.TokenExchangeService.set_access_token",
|
||||
side_effect=SessionNotFoundError("Session not found."),
|
||||
)
|
||||
def test_exchange_returns_when_session_missing(mock_set_access_token):
|
||||
"""Should return 404 when the session bound to the transit token is gone."""
|
||||
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Session not found."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_error",
|
||||
[SessionDataError, SessionExpiredError, SuspiciousSessionError],
|
||||
)
|
||||
def test_exchange_on_invalid_session(service_error):
|
||||
"""Should return 400 on malformed, expired, or suspicious sessions."""
|
||||
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=UserFactory())
|
||||
|
||||
with patch(
|
||||
"core.addons.service.TokenExchangeService.set_access_token",
|
||||
side_effect=service_error("boom"),
|
||||
):
|
||||
response = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid or expired session."}
|
||||
|
||||
|
||||
def test_exchange_rejects_non_post_methods():
|
||||
"""Should return 405 Method Not Allowed on non-POST verbs."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
for method in ("get", "put", "patch", "delete"):
|
||||
response = getattr(api_client, method)("/api/v1.0/addons/sessions/exchange/")
|
||||
assert response.status_code == 405, f"{method.upper()} should be rejected"
|
||||
|
||||
|
||||
def test_exchange_binds_to_authenticated_user():
|
||||
"""Should pass the authenticated user to set_access_token."""
|
||||
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
expected_user = UserFactory()
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=expected_user)
|
||||
|
||||
with patch(
|
||||
"core.addons.service.TokenExchangeService.set_access_token",
|
||||
return_value=None,
|
||||
) as mock_set:
|
||||
response = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_set.assert_called_once()
|
||||
called_user, _called_session_id = mock_set.call_args.args
|
||||
assert called_user == expected_user
|
||||
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
Unit tests for TokenExchangeService.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import pytest
|
||||
|
||||
from core.addons.service import (
|
||||
_PUBLIC_SESSION_FIELDS,
|
||||
CSRFTokenError,
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SessionState,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
TransitTokenError,
|
||||
TransitTokenState,
|
||||
)
|
||||
|
||||
from ...factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
"""Fresh service instance per test."""
|
||||
return TokenExchangeService()
|
||||
|
||||
|
||||
# ==============================
|
||||
# init
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_init_service_improperly_configured_secret_token(settings):
|
||||
"""Should raise ImproperlyConfigured when ADDONS_TOKEN_SECRET_KEY is unset."""
|
||||
|
||||
settings.ADDONS_TOKEN_SECRET_KEY = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="Secret key is required."):
|
||||
TokenExchangeService()
|
||||
|
||||
|
||||
def test_init_service_improperly_configured_token_scope(settings):
|
||||
"""Should raise ImproperlyConfigured when ADDONS_TOKEN_SCOPE is empty."""
|
||||
|
||||
settings.ADDONS_TOKEN_SCOPE = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="Token scope must be defined."):
|
||||
TokenExchangeService()
|
||||
|
||||
|
||||
def test_init_service_raises_when_csrf_secret_missing(settings):
|
||||
"""Should raise ImproperlyConfigured when ADDONS_CSRF_SECRET is unset."""
|
||||
settings.ADDONS_CSRF_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="CSRF Secret is required."):
|
||||
TokenExchangeService()
|
||||
|
||||
|
||||
# ==============================
|
||||
# init_session
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_init_session_returns_three_distinct_tokens(service):
|
||||
"""Should return (transit_token, session_id, csrf_token), all distinct and non-empty."""
|
||||
transit_token, session_id, csrf_token = service.init_session()
|
||||
|
||||
assert transit_token
|
||||
assert session_id
|
||||
assert csrf_token
|
||||
assert len({transit_token, session_id, csrf_token}) == 3
|
||||
|
||||
|
||||
def test_init_session_starts_in_pending_state(service):
|
||||
"""Should return a pending initialized session."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
session_data = cache.get(f"addons_sid_{session_id}")
|
||||
|
||||
assert session_data["state"] == SessionState.PENDING
|
||||
assert "access_token" not in session_data
|
||||
|
||||
transit_data = cache.get(f"addons_transit_{transit_token}")
|
||||
|
||||
# Transit token should be bind to the same session
|
||||
assert transit_data.get("session_id") == session_id
|
||||
assert transit_data.get("state") == TransitTokenState.PENDING
|
||||
|
||||
|
||||
def test_init_session_bind_transit_token_with_session(service):
|
||||
"""Should bind transit_token with the initialized session."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
transit_data = cache.get(f"addons_transit_{transit_token}")
|
||||
|
||||
assert transit_data.get("session_id") == session_id
|
||||
assert transit_data.get("state") == TransitTokenState.PENDING
|
||||
|
||||
|
||||
def test_init_session_creates_independent_cache_entries(service):
|
||||
"""Should write to distinct cache keys when called multiple times."""
|
||||
transit_a, session_id_a, csrf_a = service.init_session()
|
||||
transit_b, session_id_b, csrf_b = service.init_session()
|
||||
|
||||
assert transit_a != transit_b
|
||||
assert session_id_a != session_id_b
|
||||
assert csrf_a != csrf_b
|
||||
|
||||
|
||||
def test_init_session_csrf_token_is_derived_from_session_id(service, settings):
|
||||
"""Should derive the csrf_token as HMAC(session_id, ADDONS_CSRF_SECRET)."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
# Same inputs, same output: derivation is pure.
|
||||
assert csrf_token == service._derive_csrf_token(session_id)
|
||||
assert csrf_token == service._derive_csrf_token(
|
||||
session_id
|
||||
) # deterministic across calls
|
||||
|
||||
assert len(csrf_token) == 64
|
||||
assert all(c in "0123456789abcdef" for c in csrf_token)
|
||||
|
||||
# CSRF token is bound to the secret: rotating it invalidates outstanding tokens.
|
||||
settings.ADDONS_CSRF_SECRET = "another-secret-entirely"
|
||||
assert csrf_token != service._derive_csrf_token(session_id)
|
||||
|
||||
# CSRF token is bound to the session_id: same secret, different session ≠ same token.
|
||||
settings.ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # restore
|
||||
_, other_session_id, _ = service.init_session()
|
||||
assert service._derive_csrf_token(session_id) != service._derive_csrf_token(
|
||||
other_session_id
|
||||
)
|
||||
|
||||
|
||||
def test_init_session_tokens_have_sufficient_entropy(service):
|
||||
"""Should be long enough by default that collision is negligible."""
|
||||
transit_token, session_id, csrf_token = service.init_session()
|
||||
|
||||
assert len(transit_token) >= 40
|
||||
assert len(session_id) >= 40
|
||||
assert len(csrf_token) == 64
|
||||
|
||||
|
||||
def test_init_session_respects_configured_ttls(service, settings):
|
||||
"""Should respect their respective TTL configured through settings."""
|
||||
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
session_a_ttl = cache.ttl(f"addons_sid_{session_id}")
|
||||
transit_a_ttl = cache.ttl(f"addons_transit_{transit_token}")
|
||||
|
||||
# By default, transit token has a shorter TTL
|
||||
assert transit_a_ttl < session_a_ttl
|
||||
|
||||
settings.ADDONS_SESSION_TTL = 3000
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL = 60
|
||||
|
||||
transit_token_b, session_id_b, _ = service.init_session()
|
||||
|
||||
session_b_ttl = cache.ttl(f"addons_sid_{session_id_b}")
|
||||
transit_b_ttl = cache.ttl(f"addons_transit_{transit_token_b}")
|
||||
|
||||
assert abs(session_b_ttl - 3000) <= 2
|
||||
assert abs(transit_b_ttl - 60) <= 2
|
||||
|
||||
|
||||
# ==============================
|
||||
# verify_csrf
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_verify_csrf_accepts_matching_token(service):
|
||||
"""Should verify against its session_id."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
assert service.verify_csrf(session_id, csrf_token) is None
|
||||
|
||||
|
||||
def test_verify_csrf_is_deterministic_for_same_session(service):
|
||||
"""Should yield the same token when deriving CSRF twice."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
# Verify once, then verify again, both must succeed because
|
||||
# _derive_csrf_token is a pure function of session_id + secret.
|
||||
# without raising exceptions;
|
||||
assert service.verify_csrf(session_id, csrf_token) is None
|
||||
assert service.verify_csrf(session_id, csrf_token) is None
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_after_secret_rotation(service, settings):
|
||||
"""Should invalidate tokens issued under the old secret when ADDONS_CSRF_SECRET was rotated."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
# Rotate the secret
|
||||
settings.ADDONS_CSRF_SECRET = "different-secret-entirely"
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id, csrf_token)
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_foreign_token(service):
|
||||
"""Should reject against another csrf_token."""
|
||||
_, session_id_a, _ = service.init_session()
|
||||
_, _, csrf_b = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id_a, csrf_b)
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_random_token(service):
|
||||
"""Should reject against a random csrf_token."""
|
||||
_, session_id_a, _ = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id_a, "wrong-csrf-value")
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_empty_token(service):
|
||||
"""Should reject against an empty csrf_token."""
|
||||
_, session_id_a, _ = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id_a, "")
|
||||
|
||||
|
||||
def test_verify_csrf_is_case_sensitive(service):
|
||||
"""Should be case-sensitive (HMAC output is lowercase hex)."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id, csrf_token.upper())
|
||||
|
||||
|
||||
# ==============================
|
||||
# get_session
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_get_session_raises_when_missing(service):
|
||||
"""Should raise SessionNotFoundError for an unknown session_id."""
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.get_session("nonexistent-session-id")
|
||||
|
||||
|
||||
def test_get_session_authenticated_returns_token_then_evicts(service):
|
||||
"""Should return tokens once and evict session when authenticated."""
|
||||
|
||||
user = UserFactory()
|
||||
transit_token_a, session_id_a, _ = service.init_session()
|
||||
_, session_id_b, _ = service.init_session()
|
||||
|
||||
# Authenticate the session
|
||||
service.consume_transit_token(transit_token_a)
|
||||
service.set_access_token(user, session_id_a)
|
||||
|
||||
# First read: returns the token payload.
|
||||
result = service.get_session(session_id_a)
|
||||
assert result["state"] == SessionState.AUTHENTICATED
|
||||
assert "access_token" in result
|
||||
|
||||
# Assert session_a is evicted from the cache
|
||||
session_data_a = cache.get(f"addons_sid_{session_id_a}")
|
||||
assert session_data_a is None
|
||||
|
||||
# Second read: binding was evicted.
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.get_session(session_id_a)
|
||||
|
||||
# Assert session_b is untouched
|
||||
session_data_b = cache.get(f"addons_sid_{session_id_b}")
|
||||
assert session_data_b is not None
|
||||
assert session_data_b.get("state") == SessionState.PENDING
|
||||
|
||||
|
||||
def test_get_session_pending_preserve_cache(service):
|
||||
"""Should keep session state in cache when the session is pending."""
|
||||
|
||||
_, session_id, _ = service.init_session()
|
||||
|
||||
# First read: returns the pending session.
|
||||
result_1 = service.get_session(session_id)
|
||||
assert result_1["state"] == SessionState.PENDING
|
||||
assert "access_token" not in result_1
|
||||
|
||||
# Second read: returns the pending session.
|
||||
result_2 = service.get_session(session_id)
|
||||
assert result_2["state"] == SessionState.PENDING
|
||||
assert "access_token" not in result_2
|
||||
|
||||
|
||||
def test_get_session_pending_only_exposes_public_fields(service):
|
||||
"""Should only return whitelisted public fields when session is pending."""
|
||||
_, session_id, _ = service.init_session()
|
||||
|
||||
session = service.get_session(session_id)
|
||||
|
||||
assert set(session.keys()) <= _PUBLIC_SESSION_FIELDS
|
||||
assert session["state"] == SessionState.PENDING
|
||||
|
||||
assert "expires_at" not in session
|
||||
assert "transit_token" not in session
|
||||
|
||||
|
||||
def test_get_session_authenticated_only_exposes_public_fields(service):
|
||||
"""Should only return whitelisted public fields when session is authenticated."""
|
||||
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
# Authenticate the session
|
||||
user = UserFactory()
|
||||
service.consume_transit_token(transit_token)
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
session = service.get_session(session_id)
|
||||
|
||||
assert session["state"] == SessionState.AUTHENTICATED
|
||||
assert set(session.keys()) <= _PUBLIC_SESSION_FIELDS
|
||||
|
||||
assert "expires_at" not in session
|
||||
assert "transit_token" not in session
|
||||
|
||||
|
||||
def test_get_session_empty_string(service):
|
||||
"""Should raise SessionNotFoundError if session is empty."""
|
||||
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.get_session("")
|
||||
|
||||
|
||||
def test_get_session_corrupted_session_data(service):
|
||||
"""Should raise SessionDataError if session's data is corrupted."""
|
||||
session_id = "mock-corrupted-session-id"
|
||||
cache.set(f"addons_sid_{session_id}", {"invalid": "invalid-value"})
|
||||
|
||||
with pytest.raises(
|
||||
SessionDataError, match="Invalid session data: missing state field."
|
||||
):
|
||||
service.get_session(session_id)
|
||||
|
||||
|
||||
# ==============================
|
||||
# consume_transit_token
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_consume_transit_token_returns_session_id(service):
|
||||
"""Should return the session_id the transit token was bound to."""
|
||||
_, session_id, _ = service.init_session()
|
||||
transit_token = cache.get(f"addons_sid_{session_id}")["transit_token"]
|
||||
|
||||
returned_session_id = service.consume_transit_token(transit_token)
|
||||
|
||||
assert returned_session_id == session_id
|
||||
|
||||
|
||||
def test_consume_transit_token_replay_raises(service):
|
||||
"""Should raise on the second consume of the same transit token."""
|
||||
transit_token, _, _ = service.init_session()
|
||||
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
with pytest.raises(TransitTokenError, match="Transit token already consumed."):
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
|
||||
def test_consume_transit_token_replay_evicts_session(service):
|
||||
"""Should evict the session as security cleanup when a replay is detected."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
service.consume_transit_token(transit_token)
|
||||
assert service.get_session(session_id)
|
||||
|
||||
with pytest.raises(TransitTokenError):
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# After replay, the session is gone.
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
service.get_session(session_id)
|
||||
|
||||
|
||||
def test_consume_transit_token_raises_on_unknown_token(service):
|
||||
"""Should raise TransitTokenError when the transit token is unknown or expired."""
|
||||
|
||||
with pytest.raises(TransitTokenError, match="Invalid or expired transit token."):
|
||||
service.consume_transit_token("nonexistent-transit-token")
|
||||
|
||||
|
||||
def test_consume_transit_token_replay_when_session_already_gone(service):
|
||||
"""Should still detect replay even if the session was evicted independently."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# Simulate session evicted independently
|
||||
cache.delete(f"addons_sid_{session_id}")
|
||||
|
||||
with pytest.raises(TransitTokenError, match="Transit token already consumed."):
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
|
||||
def test_consume_transit_token_extends_ttl_for_replay_detection(service, settings):
|
||||
"""Should extend the consumed transit entry's TTL to session length."""
|
||||
settings.ADDONS_SESSION_TTL = 3000
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL = 60
|
||||
|
||||
transit_token, _, _ = service.init_session()
|
||||
|
||||
# Before consume: transit has the short TTL.
|
||||
assert cache.ttl(f"addons_transit_{transit_token}") <= 60 + 1
|
||||
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# After consume: TTL is extended to session length.
|
||||
assert cache.ttl(f"addons_transit_{transit_token}") > 60
|
||||
|
||||
|
||||
# ==============================
|
||||
# set_access_token
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_set_access_token_writes_jwt_fields_to_session(service, settings):
|
||||
"""Should populate the session with JWT fields and flip state to authenticated."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
session = service.get_session(session_id)
|
||||
assert session["state"] == SessionState.AUTHENTICATED
|
||||
assert session["access_token"]
|
||||
assert session["token_type"] == settings.ADDONS_TOKEN_TYPE
|
||||
assert session["expires_in"] == settings.ADDONS_TOKEN_TTL
|
||||
assert session["scope"] == settings.ADDONS_TOKEN_SCOPE
|
||||
|
||||
|
||||
def test_set_access_token_preserves_remaining_ttl(service, settings):
|
||||
"""Should inherit the pending session's remaining TTL rather than resetting it."""
|
||||
settings.ADDONS_SESSION_TTL = 3000
|
||||
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
ttl_before = cache.ttl(f"addons_sid_{session_id}")
|
||||
service.set_access_token(user, session_id)
|
||||
ttl_after = cache.ttl(f"addons_sid_{session_id}")
|
||||
|
||||
# TTL must not jump back to full — allow small tolerance for execution time.
|
||||
assert ttl_after <= ttl_before + 1
|
||||
# And it shouldn't have somehow grown beyond the session length either.
|
||||
assert ttl_after <= 3000
|
||||
|
||||
|
||||
def test_authenticating_one_session_leaves_others_pending(service):
|
||||
"""Should leave other pending sessions untouched when authenticating one."""
|
||||
user = UserFactory()
|
||||
|
||||
transit_a, session_id_a, _ = service.init_session()
|
||||
_, session_id_b, _ = service.init_session()
|
||||
|
||||
service.consume_transit_token(transit_a)
|
||||
service.set_access_token(user, session_id_a)
|
||||
|
||||
session_b = service.get_session(session_id_b)
|
||||
assert session_b["state"] == SessionState.PENDING
|
||||
assert "access_token" not in session_b
|
||||
|
||||
|
||||
def test_set_access_token_raises_when_transit_entry_missing(service):
|
||||
"""Should raise when the transit cache entry is gone (TTL expired or evicted)."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
# Manually delete the transit entry, simulating expiry or eviction.
|
||||
cache.delete(f"addons_transit_{transit_token}")
|
||||
|
||||
with pytest.raises(SuspiciousSessionError, match="Transit token not found."):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_raises_if_transit_token_not_consumed(service):
|
||||
"""Should refuse to authenticate a session whose transit token hasn't been consumed."""
|
||||
|
||||
user = UserFactory()
|
||||
_, session_id, _ = service.init_session()
|
||||
|
||||
with pytest.raises(SuspiciousSessionError, match="Transit token not consumed."):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
assert cache.get(f"addons_sid_{session_id}") is None
|
||||
|
||||
|
||||
def test_set_access_token_raises_on_missing_transit_token_field(service):
|
||||
"""Should raise SessionDataError when session data is missing the transit_token field."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
del corrupted["transit_token"]
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionDataError, match="missing transit_token field"):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_raises_if_double_authenticated(service):
|
||||
"""Should raise and wipe the session on double-auth while leaving the transit token intact."""
|
||||
user = UserFactory()
|
||||
transit_token, _, _ = service.init_session()
|
||||
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousSessionError, match="Session is not in pending state."
|
||||
):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
# Nuke session data as a security cleanup
|
||||
session_data = cache.get(f"addons_sid_{session_id}")
|
||||
assert session_data is None
|
||||
|
||||
transit_data = cache.get(f"addons_transit_{transit_token}")
|
||||
assert transit_data.get("state") == TransitTokenState.CONSUMED
|
||||
|
||||
|
||||
def test_set_access_token_raises_when_session_missing(service):
|
||||
"""Should raise SessionNotFoundError when called with an unknown session_id."""
|
||||
user = UserFactory()
|
||||
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.set_access_token(user, "nonexistent-session-id")
|
||||
|
||||
|
||||
def test_set_access_token_rejects_malformed_expires_at(service):
|
||||
"""Should raise SessionDataError when the cached expires_at is not valid ISO 8601."""
|
||||
user = UserFactory()
|
||||
transit_token, _, _ = service.init_session()
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
|
||||
# Corrupt the cached session directly.
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
corrupted["expires_at"] = "not-an-iso-string"
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionDataError, match="malformed expiration"):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_rejects_missing_expires_at(service):
|
||||
"""Should raise SessionDataError when the cached session is missing the expires_at field."""
|
||||
user = UserFactory()
|
||||
transit_token, _, _ = service.init_session()
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
del corrupted["expires_at"]
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionDataError, match="missing expiration"):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_raises_when_session_expired(service):
|
||||
"""Should raise SessionExpiredError when the cached session's expires_at is in the past."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# Simulate expiry: rewrite expires_at into the past.
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
corrupted["expires_at"] = "2020-01-01T00:00:00+00:00"
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionExpiredError, match="Session expired."):
|
||||
service.set_access_token(user, session_id)
|
||||
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc == "localhost:9000"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test worker service factories.
|
||||
"""
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import Mock
|
||||
@@ -10,6 +10,9 @@ from unittest.mock import Mock
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit import (
|
||||
api as livekit_api_codec,
|
||||
)
|
||||
|
||||
from core.recording.worker.factories import (
|
||||
WorkerService,
|
||||
@@ -63,6 +66,8 @@ def test_config_initialization(default_config):
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
}
|
||||
# Encoding override is opt-in; disabled by default.
|
||||
assert default_config.encoding_options is None
|
||||
|
||||
|
||||
def test_config_immutability(default_config):
|
||||
@@ -71,6 +76,45 @@ def test_config_immutability(default_config):
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
AWS_S3_REGION_NAME="test-region",
|
||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||
RECORDING_ENCODING_ENABLED=True,
|
||||
RECORDING_ENCODING_WIDTH=1280,
|
||||
RECORDING_ENCODING_HEIGHT=720,
|
||||
RECORDING_ENCODING_FRAMERATE=15,
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||
)
|
||||
def test_config_encoding_options_enabled():
|
||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
||||
|
||||
The dict mixes operator-tunable values from settings with pinned codec /
|
||||
frequency constants, so the services layer can simply unpack it.
|
||||
"""
|
||||
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options == {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 600,
|
||||
"audio_bitrate": 64,
|
||||
"key_frame_interval": 10.0,
|
||||
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
|
||||
@@ -39,6 +39,31 @@ def config():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_with_encoding(config):
|
||||
"""Fixture for a config carrying custom encoding options.
|
||||
|
||||
Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()`
|
||||
(operator-tunable values + pinned codec / frequency constants).
|
||||
"""
|
||||
return WorkerServiceConfig(
|
||||
output_folder=config.output_folder,
|
||||
server_configurations=config.server_configurations,
|
||||
bucket_args=config.bucket_args,
|
||||
encoding_options={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 600,
|
||||
"audio_bitrate": 64,
|
||||
"key_frame_interval": 10.0,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_upload():
|
||||
"""Fixture for mocked S3Upload"""
|
||||
@@ -224,6 +249,41 @@ def test_video_composite_egress_start_missing_egress_id(video_service):
|
||||
assert "Egress ID not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_video_composite_egress_start_without_encoding_options(video_service):
|
||||
"""When no encoding options are configured, no `advanced` field is set.
|
||||
|
||||
LiveKit then falls back to its built-in preset (H264_720P_30).
|
||||
"""
|
||||
video_service._handle_request.return_value = Mock(egress_id="eg-1")
|
||||
video_service.start("test-room", "rec-1")
|
||||
|
||||
request = video_service._handle_request.call_args[0][0]
|
||||
# Proto oneof `options` must be unset when no advanced encoding is provided.
|
||||
assert request.WhichOneof("options") is None
|
||||
|
||||
|
||||
def test_video_composite_egress_start_with_encoding_options(config_with_encoding):
|
||||
"""Custom encoding options are forwarded as `advanced` EncodingOptions."""
|
||||
service = VideoCompositeEgressService(config_with_encoding)
|
||||
service._handle_request = Mock(return_value=Mock(egress_id="eg-2"))
|
||||
|
||||
service.start("test-room", "rec-2")
|
||||
|
||||
request = service._handle_request.call_args[0][0]
|
||||
assert request.WhichOneof("options") == "advanced"
|
||||
|
||||
advanced = request.advanced
|
||||
assert advanced.width == 1280
|
||||
assert advanced.height == 720
|
||||
assert advanced.framerate == 15
|
||||
assert advanced.video_bitrate == 600
|
||||
assert advanced.audio_bitrate == 64
|
||||
assert advanced.key_frame_interval == pytest.approx(10.0)
|
||||
assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert advanced.audio_codec == livekit_api.AudioCodec.AAC
|
||||
assert advanced.audio_frequency == 48000
|
||||
|
||||
|
||||
def test_audio_composite_egress_hrid(audio_service):
|
||||
"""Test HRID is correct"""
|
||||
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
|
||||
|
||||
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"""
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory(
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -140,16 +140,18 @@ def test_start_recording_worker_error(
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to start for room {room.slug}"
|
||||
}
|
||||
|
||||
# Recording object should be created even if worker fails
|
||||
# Recording object should be created even if worker fails, and moved out
|
||||
# of the unique-constraint window so the room is not locked.
|
||||
assert Recording.objects.count() == 1
|
||||
recording = Recording.objects.first()
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
assert recording.status == "failed_to_start"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
@@ -158,6 +160,72 @@ def test_start_recording_worker_error(
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["active", "initiated"],
|
||||
)
|
||||
def test_start_recording_conflict_when_already_in_progress(
|
||||
status, mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should return 409 when a second start is attempted while a recording is already active."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Pre-existing active recording for the same room.
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": f"A recording is already in progress for room {room.slug}"
|
||||
}
|
||||
# No new recording row, no access row leaked from the rolled-back transaction.
|
||||
assert Recording.objects.count() == 1
|
||||
assert Recording.objects.first().accesses.count() == 0
|
||||
mock_worker_manager.start.assert_not_called()
|
||||
|
||||
|
||||
def test_start_recording_after_worker_failure_unblocks_room(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should allow a new recording when the previous recording failed."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_worker_manager.start = mock.Mock(
|
||||
side_effect=[RecordingStartError("boom"), None]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert first.status_code == 502
|
||||
|
||||
second = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert Recording.objects.count() == 2
|
||||
|
||||
|
||||
def test_start_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
@@ -277,6 +345,7 @@ def test_start_recording_options_transcribe_valid_true(
|
||||
):
|
||||
"""Should accept transcribe with any valid pydantic true values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
@@ -487,6 +556,93 @@ def test_start_recording_options_original_mode_omitted(
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_calls_metadata_collector_start(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should call MetadataCollectorService.start when conditions are met."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.viewsets.MetadataCollectorService"
|
||||
) as mock_collector_class:
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"transcribe": True, "collect_metadata": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
mock_collector.start.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata_enabled,options",
|
||||
[
|
||||
# Metadata collector disabled, regardless of transcribe option
|
||||
(False, {"transcribe": True}),
|
||||
(False, {"transcribe": False}),
|
||||
(False, None),
|
||||
# Metadata collector enabled, but transcribe is False or missing
|
||||
(True, {"transcribe": False}),
|
||||
(True, None),
|
||||
# Metadata collector enabled, transcribe True, but collect_metadata explicitly False
|
||||
(True, {"transcribe": True, "collect_metadata": False}),
|
||||
],
|
||||
)
|
||||
def test_start_recording_does_not_call_metadata_collector_start_when_conditions_not_met(
|
||||
settings,
|
||||
mock_worker_service_factory,
|
||||
mock_worker_manager,
|
||||
metadata_enabled,
|
||||
options,
|
||||
):
|
||||
"""Should not call MetadataCollectorService.start when conditions are not met."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
payload = {"mode": "screen_recording"}
|
||||
if options is not None:
|
||||
payload["options"] = options
|
||||
|
||||
with mock.patch(
|
||||
"core.api.viewsets.MetadataCollectorService"
|
||||
) as mock_collector_class:
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_collector.start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
|
||||
def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
"""Should reject invalid recording mode values for original_mode."""
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_api_rooms_update_members():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -104,7 +104,98 @@ def test_api_rooms_update_administrators():
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"the_key": "the_value"}
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration",
|
||||
[
|
||||
{},
|
||||
{"can_publish_sources": ["camera", "microphone"]},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
"camera",
|
||||
"microphone",
|
||||
"screen_share",
|
||||
"screen_share_audio",
|
||||
]
|
||||
},
|
||||
{"can_publish_sources": []},
|
||||
{"can_publish_sources": None},
|
||||
],
|
||||
)
|
||||
def test_api_rooms_update_configuration_valid(configuration):
|
||||
"""Administrators should be allowed to set valid configurations."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": configuration},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == configuration
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_extra_keys_rejected():
|
||||
"""Extra keys in configuration should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"configuration": {
|
||||
"can_publish_sources": ["camera"],
|
||||
"arbitrary_key": "value",
|
||||
}
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"])
|
||||
def test_api_rooms_update_configuration_invalid_source_value(invalid_source):
|
||||
"""Invalid source values should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": [invalid_source]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_wrong_type():
|
||||
"""Configuration values with wrong types should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": "camera"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
|
||||
@@ -269,6 +269,63 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
options={"metadata_collector_dispatch_id": "dispatch-123"},
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_collector.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata_enabled,options",
|
||||
[
|
||||
(True, {}),
|
||||
(False, {}),
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should not call MetadataCollectorService.stop when it does not exist."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
options=options,
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_collector.stop.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
|
||||
@@ -22,6 +22,28 @@ from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, Us
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_addons_test_token(user, scopes, **overrides):
|
||||
"""Generate a valid JWT token signed with the addons secret for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.ADDONS_TOKEN_TTL),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
}
|
||||
payload.update(overrides)
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def generate_test_token(user, scopes):
|
||||
"""Generate a valid JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -123,6 +145,25 @@ def test_api_rooms_list_with_expired_token(settings):
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_list_with_application_disabled(mock_rs_authenticate, settings):
|
||||
"""Listing rooms should return 401 when application is disabled."""
|
||||
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Generate expired token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
"""Listing rooms with invalid resource server token should return 400."""
|
||||
@@ -931,6 +972,11 @@ def test_api_rooms_token_inactive_application(settings):
|
||||
assert "application is disabled." in str(response.data).lower()
|
||||
|
||||
|
||||
# ==============================
|
||||
# Resource Server
|
||||
# ==============================
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_creates_user_on_first_authentication(settings):
|
||||
"""New user should be created during first authentication.
|
||||
@@ -1106,6 +1152,64 @@ def test_resource_server_authentication_successful(settings):
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_authentication_successful_when_application_disabled(settings):
|
||||
"""Resource server should keep working when the application auth backend is disabled."""
|
||||
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
other_user = UserFactory()
|
||||
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room_user_accesses = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[user]
|
||||
)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
|
||||
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "core.external_api.authentication.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
expected_ids = {str(room_user_accesses.id)}
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
"""Requests should be denied when the token lacks required scopes.
|
||||
@@ -1147,3 +1251,245 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ==============================
|
||||
# Addons
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_addons_token():
|
||||
"""Listing rooms with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_with_valid_addons_token():
|
||||
"""Retrieving a room with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_create_with_valid_addons_token():
|
||||
"""Creating a room with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_inactive_user():
|
||||
"""Addons token for an inactive user should return 401."""
|
||||
user = UserFactory(is_active=False)
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_expired(settings):
|
||||
"""Listing rooms with an expired addons token should return 401."""
|
||||
settings.ADDONS_TOKEN_TTL = 0
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_missing_user_id(settings):
|
||||
"""Addons token without user_id should be rejected."""
|
||||
|
||||
# Re-encode without user_id
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": "rooms:list",
|
||||
# no user_id
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_invalid_audience(settings):
|
||||
"""Addons token with an invalid audience should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": "invalid-audience",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"user_id": str(user.id),
|
||||
"scope": "rooms:list",
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_unknown_user(settings):
|
||||
"""Addons token for an unknown user should be rejected."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user not found." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_missing_scope():
|
||||
"""Addons token without required scope should return 403."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:list"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_addons_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
"""Addons token signed with a wrong key should defer to the next authentication."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"user_id": str(user.id),
|
||||
"scope": "rooms:list",
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
"invalid-private-key-padded-to-32b!",
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_addons_disabled_defers_to_next_backend(
|
||||
mock_rs_authenticate, settings
|
||||
):
|
||||
"""When ADDONS_ENABLED is False, a valid addons token should defer to the next backend."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_addons_disabled_does_not_break_application_auth(settings):
|
||||
"""Disabling addons auth should not affect ApplicationJWTAuthentication."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Use the existing application token helper — that backend should still work
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
@@ -19,6 +19,35 @@ from core.models import ApplicationScope, User
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_applications_generate_token_application_disabled(settings):
|
||||
"""When APPLICATION_ENABLED is False, the endpoint should return 404."""
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
UserFactory(email="User.Family@example.com")
|
||||
|
||||
@@ -4,8 +4,9 @@ from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||
|
||||
from core.addons import viewsets as addons_viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
@@ -18,15 +19,19 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
router.register(
|
||||
"addons/sessions",
|
||||
addons_viewsets.SessionViewSet,
|
||||
basename="addons_sessions",
|
||||
)
|
||||
|
||||
# - External API
|
||||
external_router = DefaultRouter()
|
||||
external_router = SimpleRouter()
|
||||
external_router.register(
|
||||
"application",
|
||||
external_viewsets.ApplicationViewSet,
|
||||
basename="external_application",
|
||||
)
|
||||
|
||||
external_router.register(
|
||||
"rooms",
|
||||
external_viewsets.RoomViewSet,
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -94,11 +94,11 @@ msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||
|
||||
@@ -576,8 +576,8 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -92,11 +92,11 @@ msgstr "Creator is me"
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "You have reached the maximum number of files for this type."
|
||||
|
||||
@@ -572,8 +572,8 @@ msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -95,11 +95,11 @@ msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||
|
||||
@@ -578,8 +578,8 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -93,11 +93,11 @@ msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||
|
||||
@@ -571,8 +571,8 @@ msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -700,6 +700,44 @@ class Base(Configuration):
|
||||
RECORDING_MAX_DURATION = values.IntegerValue(
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Recording encoding options for LiveKit Egress (video composite egress only).
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
# recordings), whose request never carries advanced EncodingOptions.
|
||||
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
||||
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
||||
# output file size and CPU load on the egress worker.
|
||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
3000,
|
||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
128,
|
||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
|
||||
4.0,
|
||||
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -808,7 +846,25 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Metadata collector settings
|
||||
METADATA_COLLECTOR_ENABLED = values.BooleanValue(
|
||||
False, environ_name="METADATA_COLLECTOR_ENABLED", environ_prefix=None
|
||||
)
|
||||
METADATA_COLLECTOR_AGENT_NAME = values.Value(
|
||||
"metadata-collector",
|
||||
environ_name="METADATA_COLLECTOR_AGENT_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
METADATA_COLLECTOR_OUTPUT_FOLDER = values.Value(
|
||||
"metadata",
|
||||
environ_name="METADATA_COLLECTOR_OUTPUT_FOLDER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
|
||||
)
|
||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||
40,
|
||||
environ_name="APPLICATION_CLIENT_ID_LENGTH",
|
||||
@@ -861,6 +917,88 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Addons
|
||||
ADDONS_ENABLED = values.BooleanValue(
|
||||
False,
|
||||
environ_name="ADDONS_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SESSION_TTL = values.PositiveIntegerValue(
|
||||
3600,
|
||||
environ_name="ADDONS_SESSION_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TRANSIT_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
120,
|
||||
environ_name="ADDONS_TRANSIT_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_CSRF_SECRET = SecretFileValue(
|
||||
None,
|
||||
environ_name="ADDONS_CSRF_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_CACHE_PREFIX_SESSION = values.Value(
|
||||
"sid",
|
||||
environ_name="ADDONS_CACHE_PREFIX_SESSION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_CACHE_PREFIX_TRANSIT = values.Value(
|
||||
"transit",
|
||||
environ_name="ADDONS_CACHE_PREFIX_TRANSIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_AUDIENCE = values.Value(
|
||||
"addons",
|
||||
environ_name="ADDONS_TOKEN_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="ADDONS_TOKEN_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
7200,
|
||||
environ_name="ADDONS_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="ADDONS_TOKEN_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="ADDONS_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||
None,
|
||||
environ_name="ADDONS_TOKEN_SECRET_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_SCOPE = values.Value(
|
||||
"rooms:create",
|
||||
environ_name="ADDONS_TOKEN_SCOPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_RANDOM_TOKEN_BYTE_LENGTH = values.PositiveIntegerValue(
|
||||
60,
|
||||
environ_name="ADDONS_RANDOM_TOKEN_BYTE_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SESSION_ID_COOKIE = values.Value(
|
||||
"addonsSid",
|
||||
environ_name="ADDONS_SESSION_ID_COOKIE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_PENDING_SESSION_KEY = values.Value(
|
||||
"addons_sid",
|
||||
environ_name="ADDONS_PENDING_SESSION_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -987,12 +1125,17 @@ class Test(Base):
|
||||
"url": "http://127.0.0.1.nip.io:7880",
|
||||
}
|
||||
|
||||
APPLICATION_ENABLED = True
|
||||
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
|
||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
FILE_UPLOAD_ENABLED = True
|
||||
|
||||
ADDONS_ENABLED = True
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -33,14 +33,14 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.25",
|
||||
"django-lasuite[all]==0.0.26",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.13",
|
||||
"django==5.2.14",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
|
||||
@@ -573,16 +573,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.13"
|
||||
version = "5.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -649,7 +649,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-lasuite"
|
||||
version = "0.0.25"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
@@ -660,9 +660,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/d7/6c022ff6b2e5c6f7226005c5f62920763ddb9ae2d4948b6af6d206314036/django_lasuite-0.0.26.tar.gz", hash = "sha256:b4ffb15d3e56e366d71b527195d26198e2d26e28cc1ab1c11aefdc581db9c02b", size = 34952, upload-time = "2026-04-03T14:02:33.957Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ab/18a1505061b536616ea4b9f75154042116ca4f1a9f64467f5a3549c686d2/django_lasuite-0.0.26-py3-none-any.whl", hash = "sha256:ba812e643eae3f55a50a69bf09eaa3b8ffa4fe9dd3296b8e27b4f7bbc25e3400", size = 54158, upload-time = "2026-04-03T14:02:32.691Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1173,7 +1173,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1243,12 +1243,12 @@ requires-dist = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||
{ name = "django", specifier = "==5.2.13" },
|
||||
{ name = "django", specifier = "==5.2.14" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==8.2.0" },
|
||||
{ name = "django-filter", specifier = "==25.2" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.26" },
|
||||
{ name = "django-parler", specifier = "==2.3" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.5.4" },
|
||||
{ name = "django-redis", specifier = "==6.0.0" },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module '@fontsource-variable/lexend' {}
|
||||
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
|
||||
declare module '@fontsource/opendyslexic' {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
declare module '@react-aria/overlays' {
|
||||
export type PortalProviderContextValue = {
|
||||
getContainer: () => HTMLElement | null;
|
||||
};
|
||||
|
||||
export type PortalProviderProps = {
|
||||
getContainer: () => HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function useUNSAFE_PortalContext(): PortalProviderContextValue;
|
||||
export function UNSAFE_PortalProvider(
|
||||
props: PortalProviderProps,
|
||||
): JSX.Element;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ server {
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location = /.well-known/windows-app-web-link {
|
||||
default_type application/json;
|
||||
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
|
||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
@@ -54,7 +57,7 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
@@ -1025,6 +1028,24 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
|
||||
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lexend": {
|
||||
"version": "5.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/material-symbols-outlined": {
|
||||
"version": "5.2.34",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.34.tgz",
|
||||
@@ -1043,6 +1064,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/opendyslexic": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
|
||||
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
|
||||
@@ -1759,6 +1789,35 @@
|
||||
"ts-pattern": "5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/core/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/dev": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/dev/-/dev-1.8.2.tgz",
|
||||
@@ -1816,6 +1875,35 @@
|
||||
"ts-pattern": "5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/generator/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/is-valid-prop": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.2.tgz",
|
||||
@@ -1917,6 +2005,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/node/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/node/node_modules/prettier": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
@@ -1976,6 +2093,35 @@
|
||||
"postcss": "8.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/postcss/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-base": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-base/-/preset-base-1.8.2.tgz",
|
||||
@@ -9432,9 +9578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,8 +13,11 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
@@ -59,7 +62,7 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<svg width="102" height="72" viewBox="0 0 102 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_13227_1168)">
|
||||
<g filter="url(#filter0_d_13227_1168)">
|
||||
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#969EB0"/>
|
||||
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
|
||||
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#969EB0" stroke-width="1.1926"/>
|
||||
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#181B24" stroke-opacity="0.6" stroke-width="1.1926"/>
|
||||
<g filter="url(#filter1_d_13227_1168)">
|
||||
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_13227_1168)">
|
||||
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_d_13227_1168)">
|
||||
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_d_13227_1168)">
|
||||
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g filter="url(#filter5_d_13227_1168)">
|
||||
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#7E98FF"/>
|
||||
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
|
||||
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="url(#paint0_linear_13227_1168)" fill-opacity="0.05"/>
|
||||
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#7E98FF" stroke-width="1.1926"/>
|
||||
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#181B24" stroke-opacity="0.45" stroke-width="1.1926"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#7E98FF"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#181B24" fill-opacity="0.45"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#7E98FF"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#181B24" fill-opacity="0.45"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#FF706E"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#181B24" fill-opacity="0.45"/>
|
||||
</g>
|
||||
<g filter="url(#filter6_d_13227_1168)">
|
||||
<path d="M50.3653 53.4428C51.0628 53.4428 51.6941 53.2682 52.2593 52.9191C52.8304 52.576 53.2844 52.1135 53.6211 51.5316C53.9638 50.9558 54.1352 50.3156 54.1352 49.6112C54.1352 48.9006 53.9638 48.2543 53.6211 47.6724C53.2844 47.0965 52.8304 46.634 52.2593 46.2849C51.6941 45.9418 51.0628 45.7703 50.3653 45.7703H47.344C46.1836 45.7703 45.1555 45.6539 44.2596 45.4211C43.3637 45.1884 42.5731 44.7871 41.8876 44.2174C41.2022 43.6539 40.598 42.8698 40.0749 41.8652C39.9185 41.5711 39.7412 41.3843 39.5428 41.3046C39.3504 41.225 39.158 41.1852 38.9656 41.1852C38.7251 41.1852 38.5086 41.2924 38.3162 41.5068C38.1298 41.7151 38.0366 42.0183 38.0366 42.4165C38.0366 44.1133 38.217 45.6386 38.5777 46.9924C38.9445 48.3523 39.5037 49.5131 40.2552 50.4749C41.0128 51.4366 41.9778 52.1717 43.1503 52.6801C44.3287 53.1886 45.7266 53.4428 47.344 53.4428H50.3653ZM47.6056 42.2603V56.9161C47.6056 57.2224 47.7048 57.4858 47.9032 57.7063C48.1076 57.9268 48.3692 58.0371 48.6878 58.0371C48.9043 58.0371 49.0997 57.985 49.274 57.8809C49.4544 57.7829 49.6649 57.6175 49.9054 57.3847L57.0212 50.6035C57.1955 50.4381 57.3158 50.2697 57.3819 50.0981C57.4481 49.9266 57.4811 49.7643 57.4811 49.6112C57.4811 49.4641 57.4481 49.3049 57.3819 49.1333C57.3158 48.9618 57.1955 48.7934 57.0212 48.628L49.9054 41.7825C49.6889 41.5742 49.4815 41.4241 49.2831 41.3322C49.0907 41.2342 48.8862 41.1852 48.6698 41.1852C48.3631 41.1852 48.1076 41.2863 47.9032 41.4884C47.7048 41.6906 47.6056 41.9478 47.6056 42.2603Z" fill="#969EB0"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_13227_1168" x="7.82784" y="4.69151" width="68.1964" height="57.826" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.08608"/>
|
||||
<feGaussianBlur stdDeviation="4.08608"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_13227_1168" x="17.3569" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_13227_1168" x="17.3569" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter3_d_13227_1168" x="39.394" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter4_d_13227_1168" x="39.394" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter5_d_13227_1168" x="44.1242" y="30.6175" width="50.0479" height="44.8629" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.08608"/>
|
||||
<feGaussianBlur stdDeviation="4.08608"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter6_d_13227_1168" x="29.8645" y="37.0992" width="35.7887" height="33.1961" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.08608"/>
|
||||
<feGaussianBlur stdDeviation="4.08608"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_13227_1168" x1="69.1483" y1="34.7036" x2="69.1483" y2="63.2222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6F8F9" stop-opacity="0.975"/>
|
||||
<stop offset="1" stop-color="#F6F8F9" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_13227_1168">
|
||||
<rect width="102" height="72" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -14,12 +14,14 @@ import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
useApplyA11yFonts()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -5,4 +5,5 @@ export enum FeatureFlags {
|
||||
noiseReduction = 'noise-reduction',
|
||||
subtitles = 'subtitles',
|
||||
candidatePolling = 'candidate-polling',
|
||||
metadataCollector = 'metadata-collector',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
@@ -16,6 +16,10 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||
|
||||
// Sliding window of recent chat ids kept for deduplication. Sized to comfortably
|
||||
// cover bursts and re-emits while staying negligible in memory.
|
||||
const MAX_TRACKED_CHAT_IDS = 16
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
@@ -24,12 +28,23 @@ export const MainNotificationToast = () => {
|
||||
|
||||
const { appendReaction } = useReactions()
|
||||
|
||||
// Multiple Chat instances may re-emit the same RoomEvent.ChatMessage.
|
||||
// Dedupe against a small ring of recent ids.
|
||||
const seenChatMsgIdsRef = useRef<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
const id = chatMessage.id
|
||||
if (id) {
|
||||
const seen = seenChatMsgIdsRef.current
|
||||
if (seen.includes(id)) return
|
||||
seen.push(id)
|
||||
if (seen.length > MAX_TRACKED_CHAT_IDS) seen.shift()
|
||||
}
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { UNSAFE_PortalProvider as PortalProvider } from '@react-aria/overlays'
|
||||
import { VisualOnlyTooltipsContext } from '@/primitives/VisualOnlyTooltipsContext'
|
||||
|
||||
// Minimal base styles so the PiP window renders correctly on first paint.
|
||||
const ensureBaseStyles = (target: Document) => {
|
||||
if (target.getElementById('pip-base-styles')) return
|
||||
const style = target.createElement('style')
|
||||
style.id = 'pip-base-styles'
|
||||
style.textContent = `
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #0b0f19; }
|
||||
body { overflow: hidden; }
|
||||
* { box-sizing: border-box; }
|
||||
`
|
||||
target.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Clone existing styles to keep the PiP window visually consistent.
|
||||
const copyStyles = (source: Document, target: Document) => {
|
||||
if (target.getElementById('pip-style-clone')) return
|
||||
const marker = target.createElement('meta')
|
||||
marker.id = 'pip-style-clone'
|
||||
target.head.appendChild(marker)
|
||||
|
||||
source.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
|
||||
const cloned = node.cloneNode(true) as HTMLElement
|
||||
target.head.appendChild(cloned)
|
||||
})
|
||||
}
|
||||
|
||||
const syncThemeAttribute = (source: Document, target: Document) => {
|
||||
const theme = source.documentElement.dataset.lkTheme
|
||||
if (theme) {
|
||||
target.documentElement.dataset.lkTheme = theme
|
||||
} else {
|
||||
delete target.documentElement.dataset.lkTheme
|
||||
}
|
||||
}
|
||||
|
||||
const cssVarNameCacheByElement = new WeakMap<HTMLElement, string[]>()
|
||||
const cssVarNameCacheByUri = new Map<string, string[]>()
|
||||
|
||||
const syncCssVariables = (source: Document, target: Document) => {
|
||||
const sourceView = source.defaultView
|
||||
if (!sourceView) return
|
||||
|
||||
const getCachedVarNames = () => {
|
||||
const docEl = source.documentElement
|
||||
if (!docEl) return []
|
||||
|
||||
const cachedByElement = cssVarNameCacheByElement.get(docEl)
|
||||
if (cachedByElement) return cachedByElement
|
||||
|
||||
const cachedByUri = source.baseURI
|
||||
? cssVarNameCacheByUri.get(source.baseURI)
|
||||
: undefined
|
||||
if (cachedByUri) return cachedByUri
|
||||
|
||||
const varNames = new Set<string>()
|
||||
const collectVarsFrom = (element: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
const styles = sourceView.getComputedStyle(element)
|
||||
for (const property of Array.from(styles)) {
|
||||
if (property.startsWith('--')) {
|
||||
varNames.add(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectVarsFrom(source.documentElement)
|
||||
collectVarsFrom(source.body)
|
||||
|
||||
const result = Array.from(varNames)
|
||||
cssVarNameCacheByElement.set(docEl, result)
|
||||
if (source.baseURI) {
|
||||
cssVarNameCacheByUri.set(source.baseURI, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const varNames = getCachedVarNames()
|
||||
if (!varNames.length) return
|
||||
|
||||
const rootStyles = sourceView.getComputedStyle(source.documentElement)
|
||||
const bodyStyles = source.body
|
||||
? sourceView.getComputedStyle(source.body)
|
||||
: null
|
||||
|
||||
varNames.forEach((property) => {
|
||||
const bodyValue = bodyStyles?.getPropertyValue(property)
|
||||
const value = bodyValue || rootStyles.getPropertyValue(property)
|
||||
if (value) {
|
||||
target.documentElement.style.setProperty(property, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* React portal into a Document Picture-in-Picture window. Handles window
|
||||
* lifecycle, style/theme sync and routes React Aria overlays via
|
||||
* `UNSAFE_PortalProvider` so they render inside the PiP document.
|
||||
*/
|
||||
export const DocumentPiPPortal = ({
|
||||
isOpen,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
children: React.ReactNode
|
||||
onClose?: () => void
|
||||
}): ReactNode => {
|
||||
const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
const prevOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
closePiP()
|
||||
setContainer(null)
|
||||
containerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupported) return
|
||||
|
||||
let cancelled = false
|
||||
openPiP().then((win) => {
|
||||
if (!win || cancelled) return
|
||||
const doc = win.document
|
||||
ensureBaseStyles(doc)
|
||||
copyStyles(document, doc)
|
||||
syncThemeAttribute(document, doc)
|
||||
syncCssVariables(document, doc)
|
||||
|
||||
doc.documentElement.setAttribute('lang', document.documentElement.lang)
|
||||
doc.title = t('windowLabel')
|
||||
|
||||
const existingContainer = containerRef.current
|
||||
if (!existingContainer || existingContainer.ownerDocument !== doc) {
|
||||
const nextContainer = doc.createElement('div')
|
||||
nextContainer.id = 'pip-root'
|
||||
nextContainer.style.width = '100%'
|
||||
nextContainer.style.height = '100%'
|
||||
nextContainer.style.display = 'flex'
|
||||
nextContainer.style.alignItems = 'stretch'
|
||||
nextContainer.style.justifyContent = 'center'
|
||||
doc.body.appendChild(nextContainer)
|
||||
containerRef.current = nextContainer
|
||||
setContainer(nextContainer)
|
||||
} else {
|
||||
setContainer(existingContainer)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [closePiP, isOpen, isSupported, openPiP, t])
|
||||
|
||||
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
|
||||
useEffect(() => {
|
||||
const wasOpen = prevOpenRef.current
|
||||
prevOpenRef.current = isOpen
|
||||
|
||||
if (isOpen && !wasOpen) {
|
||||
announce(t('opened'), 'polite')
|
||||
}
|
||||
if (!isOpen && wasOpen) {
|
||||
announce(t('closed'), 'polite')
|
||||
}
|
||||
}, [isOpen, announce, t])
|
||||
|
||||
useRestoreFocus(isOpen, { restoreFocusRaf: true })
|
||||
|
||||
// Escape from either document closes PiP (unless a nested overlay handled it).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
event.preventDefault()
|
||||
onClose?.()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
pipWindow?.document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
pipWindow?.document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose, pipWindow])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
const handleClose = () => {
|
||||
containerRef.current = null
|
||||
setContainer(null)
|
||||
onClose?.()
|
||||
}
|
||||
pipWindow.addEventListener('pagehide', handleClose)
|
||||
pipWindow.addEventListener('beforeunload', handleClose)
|
||||
return () => {
|
||||
pipWindow.removeEventListener('pagehide', handleClose)
|
||||
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||
}
|
||||
}, [onClose, pipWindow])
|
||||
|
||||
const portal = useMemo(() => {
|
||||
if (!container) return null
|
||||
return createPortal(
|
||||
<PortalProvider getContainer={() => container}>
|
||||
<VisualOnlyTooltipsContext.Provider value={true}>
|
||||
{children}
|
||||
</VisualOnlyTooltipsContext.Provider>
|
||||
</PortalProvider>,
|
||||
container
|
||||
)
|
||||
}, [children, container])
|
||||
|
||||
return portal as unknown as ReactNode
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRef, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { findFirstFocusable } from '@/utils/dom'
|
||||
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
||||
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
|
||||
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
|
||||
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
|
||||
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
||||
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
|
||||
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||
import { PipOptionsMenu } from './controls/PipOptionsMenu'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
|
||||
export const CollapsibleControls = {
|
||||
HAND: 'hand',
|
||||
SCREEN_SHARE: 'screenShare',
|
||||
REACTIONS: 'reactions',
|
||||
} as const
|
||||
|
||||
export type CollapsibleControl =
|
||||
(typeof CollapsibleControls)[keyof typeof CollapsibleControls]
|
||||
|
||||
const COLLAPSE_ORDER: CollapsibleControl[] = [
|
||||
CollapsibleControls.HAND,
|
||||
CollapsibleControls.SCREEN_SHARE,
|
||||
CollapsibleControls.REACTIONS,
|
||||
]
|
||||
|
||||
const BUTTON_SLOT = 50
|
||||
const ESSENTIAL_WIDTH = 260
|
||||
|
||||
const getHiddenControls = (
|
||||
containerWidth: number,
|
||||
showScreenShare: boolean
|
||||
): Set<CollapsibleControl> => {
|
||||
const hidden = new Set<CollapsibleControl>()
|
||||
if (containerWidth <= 0) return hidden
|
||||
|
||||
const collapsible = showScreenShare
|
||||
? COLLAPSE_ORDER
|
||||
: COLLAPSE_ORDER.filter((c) => c !== CollapsibleControls.SCREEN_SHARE)
|
||||
|
||||
const available = containerWidth - ESSENTIAL_WIDTH
|
||||
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
|
||||
|
||||
for (let i = 0; i < collapsible.length - maxVisible; i++) {
|
||||
hidden.add(collapsible[i])
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
export const PipControlBar = ({
|
||||
showScreenShare,
|
||||
}: {
|
||||
showScreenShare: boolean
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width } = usePipElementSize(containerRef)
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
|
||||
const hidden = useMemo(
|
||||
() => getHiddenControls(width, showScreenShare),
|
||||
[width, showScreenShare]
|
||||
)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
handler: useCallback(() => {
|
||||
const doc = containerRef.current?.ownerDocument ?? document
|
||||
findFirstFocusable(doc.getElementById('pip-control-bar'))?.focus()
|
||||
}, []),
|
||||
})
|
||||
|
||||
return (
|
||||
<PipControls
|
||||
ref={containerRef}
|
||||
id="pip-control-bar"
|
||||
role="toolbar"
|
||||
aria-label={t('controlBar')}
|
||||
>
|
||||
<PipControlsCenter>
|
||||
<AudioDevicesControl hideMenu />
|
||||
<VideoDeviceControl hideMenu />
|
||||
{!hidden.has(CollapsibleControls.REACTIONS) && (
|
||||
<ReactionsToggle id="pip-reactions-toggle" />
|
||||
)}
|
||||
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
|
||||
<ScreenShareToggle />
|
||||
)}
|
||||
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
|
||||
<PipOptionsMenu overflowControls={hidden} />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</PipControlsCenter>
|
||||
</PipControls>
|
||||
)
|
||||
}
|
||||
|
||||
const PipControls = styled('div', {
|
||||
base: {
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
})
|
||||
|
||||
const PipControlsCenter = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||
|
||||
export const PipPlaceholder = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture.placeholder',
|
||||
})
|
||||
const { close } = useRoomPiP()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Illustration
|
||||
src="/assets/pip.svg"
|
||||
alt=""
|
||||
width={102}
|
||||
height={72}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Title>{t('title')}</Title>
|
||||
<Description>{t('description')}</Description>
|
||||
<BringBackLink onClick={close}>{t('bringBack')}</BringBackLink>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
gap: '0.5rem',
|
||||
padding: '1.5rem',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const Illustration = styled('img', {
|
||||
base: {
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Title = styled('p', {
|
||||
base: {
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const Description = styled('p', {
|
||||
base: {
|
||||
color: '#DFE2EA',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
maxWidth: '312px',
|
||||
},
|
||||
})
|
||||
|
||||
const BringBackLink = styled('button', {
|
||||
base: {
|
||||
color: '#A2B6FF',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
marginTop: '0.25rem',
|
||||
borderRadius: '2px',
|
||||
_hover: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
_focusVisible: {
|
||||
outline: '2px solid #A2B6FF',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { reactionsStore } from '@/stores/reactions'
|
||||
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
|
||||
import type { Reaction } from '@/features/reactions/types'
|
||||
|
||||
/**
|
||||
* Renders floating emoji reactions inside the PiP window.
|
||||
* Reads the same shared reactionsStore used by the main window.
|
||||
*/
|
||||
export const PipReactionPortals = () => {
|
||||
const { reactions } = useSnapshot(reactionsStore)
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactions.map((reaction) => (
|
||||
<PipFloatingReaction key={reaction.id} reaction={reaction} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<FloatingReaction
|
||||
emoji={reaction.emoji}
|
||||
speed={speed}
|
||||
scale={scale}
|
||||
name={reaction.participantName}
|
||||
isLocal={reaction.isLocal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||