Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2fa7a64d9 | |||
| 90c34dd06b | |||
| a4997e7431 | |||
| 13c7b9ad40 | |||
| ec688e728d | |||
| bf69cbc14e | |||
| 6378c1e384 | |||
| 09b7a23f51 | |||
| c1d30f6923 | |||
| 04ec967a99 | |||
| 7390673bfc | |||
| 8984d863df | |||
| 995e6fa41d | |||
| 426e6258a8 | |||
| ac520d8b34 | |||
| 33ac849d3b | |||
| 07a1425fee | |||
| 233bdce408 | |||
| 1b35e3acd9 | |||
| f8f2ce145b | |||
| bf6f7430e7 | |||
| 7c4f66f91e | |||
| 4d27f217fc | |||
| 88b722e741 | |||
| 5ea5460b17 | |||
| 1eefc49f8d | |||
| 6cbb3520ee | |||
| b2d6d33cc8 | |||
| cd19dea09e | |||
| 0ecc25bc74 | |||
| 7f817e2c0a | |||
| fc17c410ae | |||
| f490b095d8 | |||
| 04dfb9922f | |||
| 7d9f282c2e | |||
| ba8b3bda30 | |||
| ee85768940 | |||
| a9ef134210 | |||
| dee1e46173 | |||
| 60828ed895 | |||
| 4830ea5673 | |||
| e21da647a0 | |||
| 38c131e02c | |||
| 8b8f9eae92 | |||
| 4456137948 | |||
| faa86b8293 | |||
| ae9cda463e | |||
| e9e4b360a0 | |||
| 4911a7cda0 | |||
| eb74feaa0d | |||
| 318447f2b3 | |||
| 224707f4c7 | |||
| d47d13f041 | |||
| b4ced74b1f | |||
| ecf5d443d6 | |||
| 0737974f6d | |||
| 71f76a81e9 | |||
| 385da86759 | |||
| 81e3483f28 | |||
| 5e030c2a07 | |||
| 32fbedd358 | |||
| aab90650f1 | |||
| 534cf000b2 | |||
| 5bac1668fe | |||
| 5a7a0da923 | |||
| c20daafd81 | |||
| 9846a61bd0 | |||
| 388b7d172d | |||
| 288562cc0e | |||
| 79400188d8 | |||
| dcaa45ccfe | |||
| 35951ba2a6 | |||
| 72184e1370 | |||
| 1b4a8fbac2 | |||
| 1e2fad5444 | |||
| 96f97ed2d0 | |||
| 02d16cb55c | |||
| 7268ff6777 | |||
| cca5bc2186 | |||
| ec67a12fe4 | |||
| 05f32d008a | |||
| 964b3cd452 | |||
| c7ca5a621f | |||
| 90ebe231ef | |||
| 04f2a9ebdc | |||
| 6a8eb79b41 | |||
| bc35046b3a | |||
| 1612d8b2d4 | |||
| f8937fc0a1 | |||
| 97b5e3e65c | |||
| b917d82f7e | |||
| 82d146cdf5 | |||
| cbfeea0a4e | |||
| a695758da4 | |||
| 4c5b6de8f3 | |||
| cf4e347589 | |||
| fc260b2686 | |||
| cd7799997e | |||
| a2bccf4f4f | |||
| 6830250f2c | |||
| 0c0ce87947 | |||
| 597eba6e8a | |||
| 47dbc271ba | |||
| c3adcc8ff3 |
@@ -150,13 +150,14 @@ jobs:
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -322,6 +323,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:
|
||||
|
||||
@@ -83,3 +83,6 @@ docker/livekit/out
|
||||
|
||||
# LiveKit CA configuration
|
||||
docker/livekit/rootCA.pem
|
||||
|
||||
# Frontend rollup-plugin-visualizer
|
||||
/src/frontend/rollup-plugin-visualizer/*
|
||||
|
||||
@@ -8,6 +8,57 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.17.0] - 2026-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(fullstack) allow participants to mute others based on room configuration
|
||||
- ✨(frontend) add synchronizer for room metadata updates
|
||||
- ✨(frontend) make reaction toolbar responsive on small viewports
|
||||
- ✨(frontend) enable reactions on mobile devices
|
||||
- ✨(frontend) introduce picture-in-picture meeting
|
||||
- ✨(backend) add core.recording.event.parsers.S3Parser
|
||||
- ✨(summary) extended support for all video / audio files #1358
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(fullstack) simplify source serialization
|
||||
- ✨(backend) expose room configuration to all API consumers
|
||||
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
|
||||
- 🚀 (paas) remove buildpack requirements.txt to use the new uv.lock #1349
|
||||
- ✨(backend) allow room configuration and access level via external api #1260
|
||||
- ♻️(backend) prefix Swagger routes with /api
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) fix swagger and redoc documentation URLs
|
||||
|
||||
## [1.16.0] - 2026-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒️(backend) add validation of Room.configuration
|
||||
- ✨(helm) add support multiple transcribe worker / endpoint #1247
|
||||
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
|
||||
- ✨(summary) add speaker-to-participant assignment
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(summary) change tasks endpoint signature
|
||||
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
|
||||
- 🧑💻(agents) use `uv` for package management
|
||||
- ✨(summary) improve speaker-to-participant assignment
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♻(frontend) standardize role terminology across localizations
|
||||
- 🐛(backend) make start-recording atomic and fault-tolerant
|
||||
- 🔒️(frontend) room ids are generated with non-cryptographic rand
|
||||
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
|
||||
- 🐛(summary) complete webm support #1328
|
||||
- 🐛(backend) add link to "Open" text in recording email
|
||||
- 🩹(frontend) fix spacing regression in mobile control bar
|
||||
|
||||
## [1.15.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
@@ -45,6 +96,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
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ build-frontend: ## build the frontend container
|
||||
.PHONY: build-frontend
|
||||
|
||||
build-agents: ## build the multi-user-transcriber agent container
|
||||
@$(COMPOSE) build multi-user-transcriber
|
||||
@$(COMPOSE) build multi-user-transcriber-dev
|
||||
.PHONY: build-agents
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -138,7 +138,7 @@ run-agents: ## start the multi-user-transcriber agent
|
||||
.PHONY: run-agents
|
||||
|
||||
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
|
||||
.PHONY: run-agent-multi-user-transcriber
|
||||
|
||||
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
|
||||
|
||||
@@ -23,8 +23,8 @@ docker_build(
|
||||
live_update=[
|
||||
sync('../src/backend', '/app'),
|
||||
run(
|
||||
'pip install -r /app/requirements.txt',
|
||||
trigger=['./api/requirements.txt']
|
||||
'uv sync --locked --no-dev',
|
||||
trigger=['../src/backend/uv.lock', '../src/backend/pyproject.toml']
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -109,12 +109,21 @@ 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-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
# Trigger once on launch
|
||||
k8s_resource(
|
||||
'meet-backend-createsuperuser',
|
||||
resource_deps=['meet-backend-migrate'],
|
||||
trigger_mode=TRIGGER_MODE_MANUAL,
|
||||
)
|
||||
k8s_resource(
|
||||
'meet-backend-migrate',
|
||||
resource_deps=['meet-backend'],
|
||||
trigger_mode=TRIGGER_MODE_MANUAL,
|
||||
)
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
|
||||
@@ -47,4 +47,3 @@ mv src/backend/* ./
|
||||
mv deploy/paas/* ./
|
||||
|
||||
echo "3.13" > .python-version
|
||||
echo "." > requirements.txt
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -249,6 +249,7 @@ services:
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
@@ -261,6 +262,7 @@ services:
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
@@ -269,6 +271,16 @@ services:
|
||||
- action: rebuild
|
||||
path: ./src/agents
|
||||
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
@@ -330,14 +342,6 @@ services:
|
||||
- action: rebuild
|
||||
path: ./src/summary
|
||||
|
||||
multi-user-transcriber:
|
||||
build:
|
||||
context: ./src/agents
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -185,6 +185,7 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: {}
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -198,10 +199,6 @@ paths:
|
||||
description: |
|
||||
Creates a new room with secure defaults for external API usage.
|
||||
|
||||
**Restrictions:**
|
||||
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||
- Room access_level can be updated from the webapp interface.
|
||||
|
||||
**Defaults:**
|
||||
- Delegated user is set as owner
|
||||
- Room slug auto-generated for uniqueness
|
||||
@@ -218,8 +215,17 @@ paths:
|
||||
$ref: '#/components/schemas/RoomCreate'
|
||||
examples:
|
||||
emptyBody:
|
||||
summary: No parameters (default)
|
||||
value: {}
|
||||
summary: No parameters (use all defaults)
|
||||
value: { }
|
||||
withAccessLevel:
|
||||
summary: Specify access level
|
||||
value:
|
||||
access_level: "trusted"
|
||||
withConfiguration:
|
||||
summary: Provide room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
responses:
|
||||
'201':
|
||||
description: Room created successfully
|
||||
@@ -269,6 +275,7 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: {}
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -344,8 +351,56 @@ components:
|
||||
|
||||
RoomCreate:
|
||||
type: object
|
||||
description: Empty object - all room properties are auto-generated
|
||||
properties: {}
|
||||
description: |
|
||||
Optional fields for room creation. All fields have secure defaults if omitted.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
Optional room behaviour settings. Unknown fields are rejected.
|
||||
All fields are optional and default to `null` (server-side defaults apply).
|
||||
properties:
|
||||
can_publish_sources:
|
||||
type: array
|
||||
nullable: true
|
||||
description: |
|
||||
Restricts which media tracks participants are allowed to publish.
|
||||
If `null`, all sources are permitted.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- camera
|
||||
- microphone
|
||||
- screen_share
|
||||
- screen_share_audio
|
||||
example: [ "camera", "microphone" ]
|
||||
everyone_can_mute:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: |
|
||||
Whether any participant can mute others, or only the room owner/moderator.
|
||||
If `null`, the server default applies.
|
||||
example: true
|
||||
additionalProperties: false
|
||||
|
||||
RoomAccessLevel:
|
||||
type: string
|
||||
enum:
|
||||
- public
|
||||
- trusted
|
||||
- restricted
|
||||
description: |
|
||||
Controls who can join the room without going through the lobby.
|
||||
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
example: "trusted"
|
||||
|
||||
Room:
|
||||
type: object
|
||||
@@ -362,10 +417,7 @@ components:
|
||||
description: URL-friendly room identifier (auto-generated)
|
||||
example: "aze-eere-zer"
|
||||
access_level:
|
||||
type: string
|
||||
readOnly: true
|
||||
description: Room access level (always 'trusted' for API-created rooms)
|
||||
example: "trusted"
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
@@ -393,6 +445,8 @@ components:
|
||||
type: string
|
||||
description: Default country code
|
||||
example: "US"
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
OAuthError:
|
||||
type: object
|
||||
|
||||
@@ -48,7 +48,7 @@ paths:
|
||||
summary: List rooms
|
||||
description: |
|
||||
Returns a list of rooms accessible to the authenticated user.
|
||||
Only rooms where the delegated user has access will be returned.
|
||||
Only rooms where the user has access will be returned.
|
||||
operationId: listRooms
|
||||
security:
|
||||
- BearerAuth: [rooms:list]
|
||||
@@ -108,6 +108,7 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: { }
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -120,13 +121,9 @@ paths:
|
||||
summary: Create a room
|
||||
description: |
|
||||
Creates a new room with secure defaults for external API usage.
|
||||
|
||||
**Restrictions:**
|
||||
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||
- Room access_level can be updated from the webapp interface.
|
||||
|
||||
|
||||
**Defaults:**
|
||||
- Delegated user is set as owner
|
||||
- user is set as owner
|
||||
- Room slug auto-generated for uniqueness
|
||||
- Telephony PIN auto-generated when enabled
|
||||
- Creation tracked with application client_id for auditing
|
||||
@@ -141,8 +138,17 @@ paths:
|
||||
$ref: '#/components/schemas/RoomCreate'
|
||||
examples:
|
||||
emptyBody:
|
||||
summary: No parameters (default)
|
||||
value: {}
|
||||
summary: No parameters (use all defaults)
|
||||
value: { }
|
||||
withAccessLevel:
|
||||
summary: Specify access level
|
||||
value:
|
||||
access_level: "trusted"
|
||||
withConfiguration:
|
||||
summary: Provide room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
responses:
|
||||
'201':
|
||||
description: Room created successfully
|
||||
@@ -192,6 +198,7 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: { }
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -210,65 +217,58 @@ components:
|
||||
Include in requests as: `Authorization: Bearer <token>`
|
||||
|
||||
schemas:
|
||||
TokenRequest:
|
||||
type: object
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- grant_type
|
||||
- scope
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: Application client identifier
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
client_secret:
|
||||
type: string
|
||||
format: password
|
||||
writeOnly: true
|
||||
description: Application secret key
|
||||
example: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
grant_type:
|
||||
type: string
|
||||
enum:
|
||||
- client_credentials
|
||||
description: OAuth2 grant type (must be 'client_credentials')
|
||||
example: "client_credentials"
|
||||
scope:
|
||||
type: string
|
||||
format: email
|
||||
description: |
|
||||
Email address of the user to delegate.
|
||||
The application will act on behalf of this user.
|
||||
Note: This parameter is named 'scope' to align with OAuth2 conventions,
|
||||
but accepts an email address to identify the user. This design allows
|
||||
for future extensibility.
|
||||
example: "user@example.com"
|
||||
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
description: JWT access token
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
|
||||
token_type:
|
||||
type: string
|
||||
description: Token type (always 'Bearer')
|
||||
example: "Bearer"
|
||||
expires_in:
|
||||
type: integer
|
||||
description: Token lifetime in seconds
|
||||
example: 3600
|
||||
scope:
|
||||
type: string
|
||||
description: Space-separated list of granted permission scopes
|
||||
example: "rooms:list rooms:retrieve rooms:create"
|
||||
|
||||
RoomCreate:
|
||||
type: object
|
||||
description: Empty object - all room properties are auto-generated
|
||||
properties: {}
|
||||
description: |
|
||||
Optional fields for room creation. All fields have secure defaults if omitted.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
Optional room behaviour settings. Unknown fields are rejected.
|
||||
All fields are optional and default to `null` (server-side defaults apply).
|
||||
properties:
|
||||
can_publish_sources:
|
||||
type: array
|
||||
nullable: true
|
||||
description: |
|
||||
Restricts which media tracks participants are allowed to publish.
|
||||
If `null`, all sources are permitted.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- camera
|
||||
- microphone
|
||||
- screen_share
|
||||
- screen_share_audio
|
||||
example: [ "camera", "microphone" ]
|
||||
everyone_can_mute:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: |
|
||||
Whether any participant can mute others, or only the room owner/moderator.
|
||||
If `null`, the server default applies.
|
||||
example: true
|
||||
additionalProperties: false
|
||||
|
||||
RoomAccessLevel:
|
||||
type: string
|
||||
enum:
|
||||
- public
|
||||
- trusted
|
||||
- restricted
|
||||
description: |
|
||||
Controls who can join the room without going through the lobby.
|
||||
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
example: "trusted"
|
||||
|
||||
Room:
|
||||
type: object
|
||||
@@ -285,10 +285,7 @@ components:
|
||||
description: URL-friendly room identifier (auto-generated)
|
||||
example: "aze-eere-zer"
|
||||
access_level:
|
||||
type: string
|
||||
readOnly: true
|
||||
description: Room access level (always 'trusted' for API-created rooms)
|
||||
example: "trusted"
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
@@ -316,6 +313,8 @@ components:
|
||||
type: string
|
||||
description: Default country code
|
||||
example: "US"
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
OAuthError:
|
||||
type: object
|
||||
|
||||
@@ -68,6 +68,18 @@ 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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.36.0",
|
||||
"core-js": "^3.49.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -19,7 +19,7 @@
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
@@ -36,7 +36,7 @@
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.1.0"
|
||||
"webpack-dev-server": "5.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
@@ -4319,44 +4319,195 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
|
||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
|
||||
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
|
||||
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
|
||||
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
|
||||
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-rsa": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
|
||||
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
|
||||
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pfx": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
|
||||
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
|
||||
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
|
||||
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509/node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
|
||||
@@ -4370,19 +4521,6 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -4584,16 +4722,6 @@
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/office-js": {
|
||||
"version": "1.0.582",
|
||||
"resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz",
|
||||
@@ -5650,6 +5778,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
|
||||
@@ -6121,6 +6264,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bytestreamjs": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
|
||||
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -6686,21 +6839,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/copy-webpack-plugin": {
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
|
||||
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz",
|
||||
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-glob": "^3.3.2",
|
||||
"glob-parent": "^6.0.1",
|
||||
"globby": "^14.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"schema-utils": "^4.2.0",
|
||||
"serialize-javascript": "^6.0.2"
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"tinyglobby": "^0.2.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.12.0"
|
||||
"node": ">= 20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -6711,9 +6863,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
|
||||
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -8062,36 +8214,6 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -8180,16 +8302,6 @@
|
||||
"node": ">= 4.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/faye-websocket": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
|
||||
@@ -8758,37 +8870,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
|
||||
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^2.1.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.3",
|
||||
"path-type": "^6.0.0",
|
||||
"slash": "^5.1.0",
|
||||
"unicorn-magic": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globby/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -8996,23 +9077,6 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||
@@ -11061,16 +11125,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
@@ -12655,19 +12709,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
|
||||
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
|
||||
@@ -12803,6 +12844,24 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pkijs": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
|
||||
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.4.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"bytestreamjs": "^2.0.1",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -12986,6 +13045,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -13002,37 +13081,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -13519,17 +13567,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -13562,30 +13599,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
@@ -13780,17 +13793,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selfsigned": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
|
||||
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz",
|
||||
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node-forge": "^1.3.0",
|
||||
"node-forge": "^1"
|
||||
"@peculiar/x509": "^1.14.2",
|
||||
"pkijs": "^3.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
@@ -13859,13 +13872,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
|
||||
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-index": {
|
||||
@@ -14277,19 +14290,6 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
@@ -15037,6 +15037,26 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@@ -15328,19 +15348,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -15671,15 +15678,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.1.0.tgz",
|
||||
"integrity": "sha512-aQpaN81X6tXie1FoOB7xlMfCsN19pSvRAeYUHOdFWOlhpQ/LlbfTqYwwmEDFV0h8GGuqmCmKmT+pxcUV/Nt2gQ==",
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
|
||||
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.13",
|
||||
"@types/connect-history-api-fallback": "^1.5.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/express-serve-static-core": "^4.17.21",
|
||||
"@types/serve-index": "^1.9.4",
|
||||
"@types/serve-static": "^1.15.5",
|
||||
"@types/sockjs": "^0.3.36",
|
||||
@@ -15688,18 +15696,17 @@
|
||||
"bonjour-service": "^1.2.1",
|
||||
"chokidar": "^3.6.0",
|
||||
"colorette": "^2.0.10",
|
||||
"compression": "^1.7.4",
|
||||
"compression": "^1.8.1",
|
||||
"connect-history-api-fallback": "^2.0.0",
|
||||
"express": "^4.19.2",
|
||||
"express": "^4.22.1",
|
||||
"graceful-fs": "^4.2.6",
|
||||
"html-entities": "^2.4.0",
|
||||
"http-proxy-middleware": "^2.0.3",
|
||||
"http-proxy-middleware": "^2.0.9",
|
||||
"ipaddr.js": "^2.1.0",
|
||||
"launch-editor": "^2.6.1",
|
||||
"open": "^10.0.3",
|
||||
"p-retry": "^6.2.0",
|
||||
"schema-utils": "^4.2.0",
|
||||
"selfsigned": "^2.4.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"serve-index": "^1.9.1",
|
||||
"sockjs": "^0.3.24",
|
||||
"spdy": "^4.0.2",
|
||||
@@ -15728,6 +15735,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
@@ -15742,9 +15762,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/ipaddr.js": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -15771,9 +15791,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.36.0",
|
||||
"core-js": "^3.49.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.1.0"
|
||||
"webpack-dev-server": "5.2.4"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
**/.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.iml
|
||||
db.sqlite3
|
||||
.pylint.d
|
||||
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
# Env
|
||||
.env
|
||||
@@ -6,31 +6,61 @@ RUN apt-get update && apt-get install -y \
|
||||
libgobject-2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# ---- Builder image ----
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /builder
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY pyproject.toml .
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
FROM base AS development
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir ".[dev]"
|
||||
# Install production dependencies without the project itself (cacheable layer)
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
|
||||
COPY . .
|
||||
# Install the project
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
CMD ["python", "metadata_collector.py", "dev"]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM base AS development
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --all-extras
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
CMD ["python", "multi_user_transcriber.py", "dev"]
|
||||
|
||||
|
||||
# ---- Production image ----
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /install /usr/local
|
||||
# Copy the pre-built virtualenv and application source
|
||||
COPY --from=builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
@@ -39,6 +69,4 @@ RUN pip uninstall -y pip
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY ./*.py /app/
|
||||
|
||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
||||
|
||||
@@ -177,7 +177,7 @@ class MetadataCollector:
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
logger.info("Persisting metadata…")
|
||||
logger.info("Persisting metadata...")
|
||||
|
||||
participants = []
|
||||
for k, v in self.participants.items():
|
||||
@@ -372,7 +372,7 @@ async def entrypoint(ctx: JobContext):
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
async def cleanup():
|
||||
logger.info("Shutting down metadata collector…")
|
||||
logger.info("Shutting down metadata collector...")
|
||||
await metadata_collector.aclose()
|
||||
|
||||
ctx.add_shutdown_callback(cleanup)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.15.0"
|
||||
version = "1.17.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
@@ -18,12 +18,8 @@ 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"
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
|
||||
@@ -8,6 +8,8 @@ from rest_framework import views as drf_views
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.utils import build_telephony_config
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""Handle Django ValidationError as an accepted exception.
|
||||
@@ -58,13 +60,7 @@ def get_frontend_configuration(request):
|
||||
"allowed_mimetypes"
|
||||
],
|
||||
},
|
||||
"telephony": {
|
||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||
if settings.ROOM_TELEPHONY_ENABLED
|
||||
else None,
|
||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
},
|
||||
"telephony": build_telephony_config(),
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
|
||||
@@ -136,3 +136,33 @@ class FilePermission(IsAuthenticated):
|
||||
raise Http404
|
||||
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
|
||||
class CanMuteParticipant(permissions.BasePermission):
|
||||
"""
|
||||
Grant muting rights based on role or room configuration.
|
||||
|
||||
- Admins and owners can always mute.
|
||||
- When `everyone_can_mute` is enabled on the room, any participant
|
||||
currently in the room (proven by a valid LiveKit token for that room)
|
||||
can mute.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check if the requesting user is allowed to mute a participant in the given room."""
|
||||
|
||||
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
|
||||
|
||||
# Always allow admins/owners when authenticated with session cookie
|
||||
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
|
||||
return True
|
||||
|
||||
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
|
||||
if not everyone_can_mute:
|
||||
return False
|
||||
|
||||
if not is_livekit_token_auth:
|
||||
return False
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
@@ -13,7 +13,8 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
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.
|
||||
@@ -155,11 +166,6 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
configuration = output["configuration"]
|
||||
|
||||
if not is_admin_or_owner:
|
||||
del output["configuration"]
|
||||
|
||||
should_access_room = (
|
||||
(
|
||||
instance.access_level == models.RoomAccessLevel.TRUSTED
|
||||
@@ -176,7 +182,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=configuration,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
)
|
||||
else:
|
||||
@@ -306,7 +312,19 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
"""Validate room configuration structure.
|
||||
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[TrackSource] | None = None
|
||||
everyone_can_mute: bool | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class ParticipantPermission(BaseModel):
|
||||
@@ -328,6 +346,10 @@ class ParticipantPermission(BaseModel):
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
@field_serializer("can_publish_sources")
|
||||
def _serialize_sources(self, sources: list[str]) -> list[str]:
|
||||
return [s.upper() for s in sources]
|
||||
|
||||
|
||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate participant update data."""
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +33,7 @@ from rest_framework import (
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
@@ -68,12 +71,22 @@ from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.participant_promotion import (
|
||||
AlreadyAdminException,
|
||||
OwnerPromotionException,
|
||||
ParticipantPromotionService,
|
||||
)
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
@@ -297,6 +310,41 @@ class RoomViewSet(
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Persist the room update, then sync metadata to LiveKit."""
|
||||
|
||||
old_configuration = serializer.instance.configuration
|
||||
old_access_level = serializer.instance.access_level
|
||||
|
||||
room = serializer.save()
|
||||
|
||||
if (
|
||||
room.configuration == old_configuration
|
||||
and room.access_level == old_access_level
|
||||
):
|
||||
return
|
||||
|
||||
metadata = {
|
||||
"configuration": room.configuration,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
room_name=str(room.id),
|
||||
metadata=metadata,
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||
room.id,
|
||||
)
|
||||
except RoomManagementException:
|
||||
logger.warning(
|
||||
"Failed to sync metadata to LiveKit for room %s",
|
||||
room.id,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -320,16 +368,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)
|
||||
@@ -337,9 +396,12 @@ 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 (
|
||||
@@ -347,6 +409,7 @@ class RoomViewSet(
|
||||
):
|
||||
try:
|
||||
MetadataCollectorService().start(recording)
|
||||
logger.debug("Started MetadataCollectorService")
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to start MetadataCollectorService")
|
||||
|
||||
@@ -597,7 +660,11 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="mute-participant",
|
||||
url_name="mute-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
permission_classes=[permissions.CanMuteParticipant],
|
||||
authentication_classes=[
|
||||
LiveKitTokenAuthentication,
|
||||
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
|
||||
],
|
||||
)
|
||||
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Mute a specific track for a participant in the room."""
|
||||
@@ -606,6 +673,26 @@ class RoomViewSet(
|
||||
serializer = serializers.MuteParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
|
||||
# joined. Cross-check identity against the live participant list until auth
|
||||
# is hardened. Skipped for non-LiveKit auth backends.
|
||||
caller_identity = getattr(request.auth, "identity", None)
|
||||
if caller_identity is not None:
|
||||
try:
|
||||
ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(room.pk),
|
||||
identity=caller_identity,
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
logger.warning(
|
||||
"Failed to verify caller presence for mute in room %s; denying",
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": "Could not verify caller presence"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().mute(
|
||||
room_name=str(room.pk),
|
||||
@@ -793,6 +880,123 @@ class RoomViewSet(
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="promote-participant",
|
||||
url_name="promote-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
)
|
||||
# pylint: disable=unused-argument,too-many-return-statements
|
||||
def promote_participant(self, request, pk=None): # noqa: PLR0911
|
||||
"""Promote a live participant to room admin.
|
||||
|
||||
This endpoint is intentionally non-transactional: the DB transaction and the
|
||||
LiveKit attribute update are two separate operations. If the participant
|
||||
leaves the meeting between the presence check and the LiveKit update, the
|
||||
resource access is kept — their role will be effective the next time they join.
|
||||
"""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
identity = serializer.validated_data["participant_identity"]
|
||||
participant_management = ParticipantsManagement()
|
||||
|
||||
if str(identity) == str(request.user.sub):
|
||||
return drf_response.Response(
|
||||
{"error": "You cannot promote yourself"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
is_in_meeting = participant_management.check_if_in_meeting(
|
||||
room.pk, identity=str(identity)
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
logger.warning(
|
||||
"Could not verify presence of participant %s in room %s before promotion; denying",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": "Could not verify participant presence"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not is_in_meeting:
|
||||
logger.warning(
|
||||
"Participant %s is not currently in room %s; denying promotion",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": "Could not verify participant presence"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(sub=identity)
|
||||
except models.User.DoesNotExist:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Attempted to promote inactive user %s in room %s; denying",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "This participant account is inactive and cannot be promoted"
|
||||
},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
ParticipantPromotionService().promote_to_admin(room=room, user=user)
|
||||
except AlreadyAdminException:
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
except OwnerPromotionException:
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "Owners already have the highest privileges and cannot be promoted"
|
||||
},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# DB transaction is intentionally persisted before the LiveKit update.
|
||||
# If the participant disconnects meanwhile, the role still applies on rejoin.
|
||||
try:
|
||||
participant_management.update(
|
||||
room_name=str(room.pk),
|
||||
identity=str(identity),
|
||||
attributes={"room_admin": "true"},
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
logger.exception(
|
||||
"LiveKit update failed for participant %s in room %s; ADMIN role persisted",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"status": "success",
|
||||
"warning": "LiveKit update failed; role update persisted",
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from pydantic import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models, utils
|
||||
from core.api.serializers import BaseValidationOnlySerializer
|
||||
from core.api.serializers import BaseValidationOnlySerializer, RoomConfiguration
|
||||
|
||||
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
|
||||
@@ -34,10 +35,37 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
following the principle of least privilege.
|
||||
"""
|
||||
|
||||
configuration = serializers.JSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
fields = ["id", "name", "slug", "pin_code", "access_level", "configuration"]
|
||||
read_only_fields = ["id", "name", "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 ValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def validate_access_level(self, access_level):
|
||||
"""Reject public access_level unless explicitly allowed or the default is already public."""
|
||||
|
||||
if settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL == models.RoomAccessLevel.PUBLIC:
|
||||
return access_level
|
||||
|
||||
if (
|
||||
access_level == models.RoomAccessLevel.PUBLIC
|
||||
and not settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Public rooms are disabled for the external API."
|
||||
)
|
||||
return access_level
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Enrich response with application-specific computed fields."""
|
||||
@@ -68,6 +96,9 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Set secure defaults
|
||||
validated_data["name"] = utils.generate_room_slug()
|
||||
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
|
||||
validated_data.setdefault(
|
||||
"access_level", settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL
|
||||
)
|
||||
validated_data.setdefault("configuration", {})
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
@@ -388,6 +388,7 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
# Public configuration exposed to any room participant via the API
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Meet storage event parser classes."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -18,6 +19,9 @@ from .exceptions import (
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
# Additional MIME type mapping
|
||||
mimetypes.add_type("audio/ogg", ".ogg")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -74,8 +78,8 @@ def get_parser() -> EventParser:
|
||||
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
||||
|
||||
|
||||
class MinioParser:
|
||||
"""Handle parsing and validation of Minio storage events."""
|
||||
class BaseS3Parser:
|
||||
"""Base class for handling parsing and validation of S3-compatible storage events."""
|
||||
|
||||
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
||||
"""Initialize parser with target bucket name and accepted filetypes."""
|
||||
@@ -91,32 +95,6 @@ class MinioParser:
|
||||
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse(data):
|
||||
"""Convert raw Minio event dictionary to StorageEvent object."""
|
||||
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
|
||||
try:
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
bucket_name = s3["bucket"]["name"]
|
||||
file_object = s3["object"]
|
||||
filepath = file_object["key"]
|
||||
filetype = file_object["contentType"]
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
|
||||
try:
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
bucket_name=bucket_name,
|
||||
metadata=None,
|
||||
)
|
||||
except TypeError as e:
|
||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||
|
||||
def validate(self, event_data: StorageEvent) -> str:
|
||||
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
||||
|
||||
@@ -141,9 +119,56 @@ class MinioParser:
|
||||
return recording_id
|
||||
|
||||
def get_recording_id(self, data):
|
||||
"""Extract recording ID from Minio event through parsing and validation."""
|
||||
"""Extract recording ID from S3 event through parsing and validation."""
|
||||
|
||||
event_data = self.parse(data)
|
||||
recording_id = self.validate(event_data)
|
||||
return self.validate(event_data)
|
||||
|
||||
return recording_id
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
"""To be implemented by subclasses."""
|
||||
raise NotImplementedError("Subclasses must implement parse()")
|
||||
|
||||
|
||||
class MinioParser(BaseS3Parser):
|
||||
"""Minio specific event parsing."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
try:
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
return StorageEvent(
|
||||
filepath=s3["object"]["key"],
|
||||
filetype=s3["object"]["contentType"], # Minio-specific field
|
||||
bucket_name=s3["bucket"]["name"],
|
||||
metadata=None,
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
|
||||
except TypeError as e:
|
||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||
|
||||
|
||||
class S3Parser(BaseS3Parser):
|
||||
"""AWS S3 specific event parsing."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
try:
|
||||
# AWS S3 structure can slightly differ from Minio implementation
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
filepath = s3["object"]["key"]
|
||||
if not filepath:
|
||||
raise ParsingEventDataError("Missing object key name")
|
||||
filetype, _ = mimetypes.guess_type(filepath)
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
bucket_name=s3["bucket"]["name"],
|
||||
metadata=None,
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class LobbyService:
|
||||
|
||||
def request_entry(
|
||||
self,
|
||||
room,
|
||||
room: models.Room,
|
||||
request,
|
||||
username: str,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Participant promotion service."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from core import models
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class PromotionException(Exception):
|
||||
"""Base exception for promotion errors."""
|
||||
|
||||
|
||||
class OwnerPromotionException(PromotionException):
|
||||
"""Raised when attempting to promote an owner."""
|
||||
|
||||
|
||||
class AlreadyAdminException(PromotionException):
|
||||
"""Raised when attempting to promote a user who is already an admin."""
|
||||
|
||||
|
||||
class ParticipantPromotionService:
|
||||
"""Handles the DB side of promoting a participant to room admin."""
|
||||
|
||||
def promote_to_admin(self, room, user) -> None:
|
||||
"""Promote a user to ADMIN role for the given room."""
|
||||
|
||||
access = models.ResourceAccess.objects.filter(resource=room, user=user).first()
|
||||
|
||||
if access and access.role == models.RoleChoices.ADMIN:
|
||||
raise AlreadyAdminException(
|
||||
f"User {user.pk} is already an admin of room {room.pk}"
|
||||
)
|
||||
|
||||
if access and access.role == models.RoleChoices.OWNER:
|
||||
raise OwnerPromotionException(
|
||||
f"User {user.pk} is an owner of room {room.pk} and cannot be promoted"
|
||||
)
|
||||
|
||||
models.ResourceAccess.objects.update_or_create(
|
||||
resource=room,
|
||||
user=user,
|
||||
defaults={"role": models.RoleChoices.ADMIN},
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from livekit.api import (
|
||||
TwirpError,
|
||||
UpdateParticipantRequest,
|
||||
)
|
||||
from livekit.protocol.models import ParticipantInfo
|
||||
|
||||
from core import utils
|
||||
|
||||
@@ -154,3 +155,44 @@ class ParticipantsManagement:
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@async_to_sync
|
||||
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
|
||||
"""Check whether `identity` is currently a participant in `room_name`.
|
||||
|
||||
Raises ParticipantsManagementException for unexpected LiveKit errors
|
||||
so callers can fail closed rather than silently allowing the action.
|
||||
"""
|
||||
|
||||
if not room_name or not identity:
|
||||
return False
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
participant = await lkapi.room.get_participant(
|
||||
RoomParticipantIdentity(
|
||||
room=room_name,
|
||||
identity=identity,
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error checking participant %s in room %s",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantsManagementException(
|
||||
"Could not verify participant presence"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
return (
|
||||
participant is not None
|
||||
and participant.state != ParticipantInfo.State.DISCONNECTED
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Room management service for LiveKit rooms."""
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
import json
|
||||
from logging import getLogger
|
||||
from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomManagementException(Exception):
|
||||
"""Exception raised when a room management operation fails."""
|
||||
|
||||
|
||||
class RoomNotFoundException(RoomManagementException):
|
||||
"""Raised when the target room does not exist in LiveKit."""
|
||||
|
||||
|
||||
class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(i.e. the Room model's UUID as a string).
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
)
|
||||
)
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error updating metadata for room %s",
|
||||
room_name,
|
||||
)
|
||||
raise RoomManagementException("Could not update room metadata") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -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)
|
||||
|
||||
@@ -18,10 +18,13 @@ from core.recording.event.exceptions import (
|
||||
)
|
||||
from core.recording.event.parsers import (
|
||||
MinioParser,
|
||||
S3Parser,
|
||||
StorageEvent,
|
||||
get_parser,
|
||||
)
|
||||
|
||||
# MinioParser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_minio_event():
|
||||
@@ -47,7 +50,7 @@ def minio_parser():
|
||||
return MinioParser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_parse_valid_event(minio_parser, valid_minio_event):
|
||||
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
|
||||
"""Test parsing a valid Minio event."""
|
||||
event = minio_parser.parse(valid_minio_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
@@ -57,13 +60,33 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_parse_empty_data(minio_parser):
|
||||
def test_minio_parse_with_video_type(minio_parser):
|
||||
"""Test parsing event with video file type."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
"contentType": "video/mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = minio_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_minio_parse_empty_data(minio_parser):
|
||||
"""Test parsing empty event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
minio_parser.parse({})
|
||||
|
||||
|
||||
def test_parse_missing_keys(minio_parser):
|
||||
def test_minio_parse_missing_keys(minio_parser):
|
||||
"""Test parsing event with missing key."""
|
||||
|
||||
invalid_minio_event = {
|
||||
@@ -77,11 +100,11 @@ def test_parse_missing_keys(minio_parser):
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
|
||||
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_parse_none_key(minio_parser):
|
||||
def test_minio_parse_none_key(minio_parser):
|
||||
"""Test parsing event with None field."""
|
||||
|
||||
invalid_minio_event = {
|
||||
@@ -102,7 +125,7 @@ def test_parse_none_key(minio_parser):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_validate_invalid_bucket(minio_parser):
|
||||
def test_minio_validate_invalid_bucket(minio_parser):
|
||||
"""Test validation with wrong bucket name."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
@@ -114,7 +137,7 @@ def test_validate_invalid_bucket(minio_parser):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_validate_invalid_filetype(minio_parser):
|
||||
def test_minio_validate_invalid_filetype(minio_parser):
|
||||
"""Test validation with unsupported file type."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
||||
@@ -139,7 +162,7 @@ def test_validate_invalid_filetype(minio_parser):
|
||||
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
|
||||
],
|
||||
)
|
||||
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
"""Test validation with malformed filepath."""
|
||||
event = StorageEvent(
|
||||
filepath=invalid_filepath,
|
||||
@@ -151,7 +174,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_validate_valid_event(minio_parser):
|
||||
def test_minio_validate_valid_event(minio_parser):
|
||||
"""Test validation with valid event data."""
|
||||
event = StorageEvent(
|
||||
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
@@ -163,13 +186,13 @@ def test_validate_valid_event(minio_parser):
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
"""Test successful extraction of recording ID."""
|
||||
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_validate_filepath_with_folder(minio_parser):
|
||||
def test_minio_validate_filepath_with_folder(minio_parser):
|
||||
"""Test validation of filepath with folder structure."""
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
@@ -181,41 +204,21 @@ def test_validate_filepath_with_folder(minio_parser):
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_parse_with_video_type(minio_parser):
|
||||
"""Test parsing event with video file type."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
"contentType": "video/mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = minio_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_empty_allowed_filetypes():
|
||||
def test_minio_empty_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
empty_types = set()
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
||||
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
||||
|
||||
|
||||
def test_custom_allowed_filetypes():
|
||||
def test_minio_custom_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
custom_types = {"audio/mp3", "video/mov"}
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
||||
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
||||
|
||||
|
||||
def test_validate_custom_filetypes():
|
||||
def test_minio_validate_custom_filetypes():
|
||||
"""Test validation of filepath with folder structure."""
|
||||
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||
@@ -229,18 +232,143 @@ def test_validate_custom_filetypes():
|
||||
parser.validate(event)
|
||||
|
||||
|
||||
def test_constructor_none_bucket():
|
||||
def test_minio_constructor_none_bucket():
|
||||
"""Test MinioParser constructor with None bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name=None)
|
||||
|
||||
|
||||
def test_constructor_empty_bucket():
|
||||
def test_minio_constructor_empty_bucket():
|
||||
"""Test MinioParser constructor with empty bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name="")
|
||||
|
||||
|
||||
# S3Parser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_s3_event():
|
||||
"""Mock a valid S3 event."""
|
||||
return {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_parser():
|
||||
"""Mock an S3 parser."""
|
||||
return S3Parser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
|
||||
"""Test parsing a valid S3 event."""
|
||||
event = s3_parser.parse(valid_s3_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filetype == "audio/ogg"
|
||||
assert event.bucket_name == "test-bucket"
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_s3_parse_empty_data(s3_parser):
|
||||
"""Test parsing empty S3 event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
s3_parser.parse({})
|
||||
|
||||
|
||||
def test_s3_parse_missing_keys(s3_parser):
|
||||
"""Test parsing S3 event with missing key."""
|
||||
invalid_s3_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
# Missing 'object' key
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
|
||||
s3_parser.parse(invalid_s3_event)
|
||||
|
||||
|
||||
def test_s3_parse_none_key(s3_parser):
|
||||
"""Test parsing S3 event with None field."""
|
||||
invalid_s3_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": None,
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
|
||||
s3_parser.parse(invalid_s3_event)
|
||||
|
||||
|
||||
def test_s3_parse_with_video_type(s3_parser):
|
||||
"""Test parsing S3 event with mp4 file extension."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = s3_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_s3_parse_unrecognized_extension(s3_parser):
|
||||
"""Test parsing S3 event with unrecognized file extension."""
|
||||
event_with_unknown_ext = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(TypeError, match="filetype cannot be None"):
|
||||
s3_parser.parse(event_with_unknown_ext)
|
||||
|
||||
|
||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||
"""Test successful extraction of recording ID from S3 event."""
|
||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
# get_parser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_lru_cache():
|
||||
"""Fixture to clear the LRU cache between tests."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
Test rooms API endpoints in the Meet core app: participants management.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
from livekit.api import TwirpError, UpdateParticipantRequest
|
||||
from livekit.protocol.models import ParticipantInfo
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
@@ -31,8 +34,8 @@ def mock_livekit_client():
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_mute_participant_success(mock_livekit_client):
|
||||
"""Test successful participant muting."""
|
||||
def test_mute_participant_success_as_admin(mock_livekit_client):
|
||||
"""Admins and owners should be able to mute without a LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
@@ -41,10 +44,12 @@ def test_mute_participant_success(mock_livekit_client):
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
@@ -53,23 +58,131 @@ def test_mute_participant_success(mock_livekit_client):
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_forbidden_without_access():
|
||||
"""Test mute participant returns 403 when user lacks room privileges."""
|
||||
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
|
||||
"""Should forbid muting when user is anonymous and no LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory() # User without UserResourceAccess
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
"""Should allow muting when the LiveKit token is scoped to this room."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should forbid muting when the LiveKit token is scoped to a different room."""
|
||||
|
||||
client = APIClient()
|
||||
target_room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client):
|
||||
"""Should forbid muting when user has no room role and no LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory() # everyone_can_mute defaults to True
|
||||
user = UserFactory() # no UserResourceAccess for this room
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should forbid muting when everyone_can_mute is False, even with a LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
|
||||
"""Should allow admins and owners to mute when everyone_can_mute is False."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_invalid_payload():
|
||||
"""Test mute participant with invalid payload."""
|
||||
"""Should reject muting when the payload is invalid."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
@@ -78,16 +191,16 @@ def test_mute_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
"""Test mute participant when LiveKit API raises TwirpError."""
|
||||
"""Should return 500 when the LiveKit API raises a TwirpError."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
@@ -101,10 +214,12 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to mute participant"}
|
||||
@@ -112,6 +227,282 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_participant_not_found(mock_livekit_client):
|
||||
"""Should return 404 when the participant does not exist in the room."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_management_exception(mock_livekit_client):
|
||||
"""Should return 500 when ParticipantsManagement raises an unexpected error."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
msg="boom", code="internal", status=503
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to mute participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
"""Should allow muting when user is admin and LiveKit token is scoped to this room."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client):
|
||||
"""Should not allow muting when user is admin and the LiveKit token is for another room."""
|
||||
client = APIClient()
|
||||
target_room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=target_room,
|
||||
user=user,
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should forbid muting when a LiveKit token issued for an admin is passed without a session."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room,
|
||||
user=admin_user,
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client):
|
||||
"""Should check participant presence when authenticated via LiveKit token only."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Presence is verified against LiveKit before the mute is issued.
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should mute when the authentified participant is currently in the room."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
# Simulate LiveKit confirming the caller is currently in the room.
|
||||
# State != DISCONNECTED (3) means present.
|
||||
mock_livekit_client.room.get_participant.return_value = ParticipantInfo(
|
||||
identity="caller-identity",
|
||||
state=ParticipantInfo.State.ACTIVE,
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should not mute when the authentified participant is not found."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify caller presence"}
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
# The presence check failed, so we never reach the mute call.
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should not mute when the presence check fail."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
||||
msg="an error occured", code="not_found", status=500
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify caller presence"}
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
# The presence check failed, so we never reach the mute call.
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client):
|
||||
"""Should not check presence of the participant when authentified with a session cookie."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Session auth has no LiveKit identity to verify against, so the
|
||||
# stop-gap presence check is skipped.
|
||||
mock_livekit_client.room.get_participant.assert_not_called()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_success(mock_livekit_client):
|
||||
"""Test successful participant update."""
|
||||
client = APIClient()
|
||||
@@ -130,8 +521,8 @@ def test_update_participant_success(mock_livekit_client):
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"MICROPHONE",
|
||||
"camera",
|
||||
"microphone",
|
||||
],
|
||||
"can_update_metadata": True,
|
||||
"can_subscribe_metrics": True,
|
||||
@@ -158,8 +549,8 @@ def test_update_participant_success(mock_livekit_client):
|
||||
{"can_publish_data": True},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"MICROPHONE",
|
||||
"camera",
|
||||
"microphone",
|
||||
]
|
||||
},
|
||||
{"can_update_metadata": True},
|
||||
@@ -190,9 +581,41 @@ def test_update_participant_permission_fields_are_optional(
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
(request_arg,), _ = mock_livekit_client.room.update_participant.call_args
|
||||
assert isinstance(request_arg, UpdateParticipantRequest)
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_permission_fields_invalid_case(mock_livekit_client):
|
||||
"""Should raise bad request when can_publish_sources is uppercase."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"microphone",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
mock_livekit_client.aclose.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,permission_key",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
Test rooms API endpoints: promote participant.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import models
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ---
|
||||
# success cases
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_success_new_access(mock_participants_management_cls):
|
||||
"""Should create a new ResourceAccess with ADMIN role and update LiveKit."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_instance.check_if_in_meeting.assert_called_once_with(
|
||||
room.pk, identity=participant_user.sub
|
||||
)
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
mock_instance.update.assert_called_once_with(
|
||||
room_name=str(room.pk),
|
||||
identity=str(participant_user.sub),
|
||||
attributes={"room_admin": "true"},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_success_upgrades_member_to_admin(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should upgrade an existing member to ADMIN role."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(resource=room, user=participant_user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_instance.check_if_in_meeting.assert_called_once_with(
|
||||
room.pk, identity=str(participant_user.sub)
|
||||
)
|
||||
|
||||
access = models.ResourceAccess.objects.get(resource=room, user=participant_user)
|
||||
assert access.role == models.RoleChoices.ADMIN
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_success_already_admin(mock_participants_management_cls):
|
||||
"""Should succeed idempotently when the participant is already an admin."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=participant_user, role="administrator"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_instance.check_if_in_meeting.assert_called_once_with(
|
||||
room.pk, identity=str(participant_user.sub)
|
||||
)
|
||||
|
||||
access = models.ResourceAccess.objects.get(resource=room, user=participant_user)
|
||||
assert access.role == models.RoleChoices.ADMIN
|
||||
mock_instance.update.assert_not_called()
|
||||
|
||||
|
||||
# ---
|
||||
# permission / auth
|
||||
# ---
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_without_authentication():
|
||||
"""Should return 401 when the request is unauthenticated."""
|
||||
room = RoomFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_for_member():
|
||||
"""Should return 403 when the requester only has member-level access."""
|
||||
room = RoomFactory()
|
||||
member = UserFactory()
|
||||
UserResourceAccessFactory(resource=room, user=member, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=member)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_for_unrelated_user():
|
||||
"""Should return 403 when the requester has no access to the room."""
|
||||
room = RoomFactory()
|
||||
unrelated_user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=unrelated_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_for_admin_on_another_room():
|
||||
"""Should return 403 when the requester is admin on a different room, not the target one."""
|
||||
target_room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
admin_other_room = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=other_room,
|
||||
user=admin_other_room,
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_other_room)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{target_room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ---
|
||||
# self-promotion
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_self_promotion(mock_participants_management_cls):
|
||||
"""Should return 403 when the requester attempts to promote themselves."""
|
||||
mock_participants_management_cls.return_value = mock.MagicMock()
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(admin_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "You cannot promote yourself"}
|
||||
|
||||
mock_participants_management_cls.check_if_in_meeting.assert_not_called()
|
||||
|
||||
|
||||
# ---
|
||||
# presence check
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_participant_not_in_meeting(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when check_if_in_meeting returns False."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.check_if_in_meeting.return_value = False
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify participant presence"}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_presence_check_fails(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when the participant is not found in the meeting."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.check_if_in_meeting.side_effect = ParticipantNotFoundException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify participant presence"}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_presence_management_exception(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when the presence check raises a management exception."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.check_if_in_meeting.side_effect = ParticipantsManagementException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify participant presence"}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# user resolution
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_not_found_when_user_never_logged_in(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 404 when the identity has no matching db user."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
unknown_sub = uuid4()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(unknown_sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
mock_instance.update.assert_not_called()
|
||||
|
||||
assert models.ResourceAccess.objects.filter(resource=room).count() == 1
|
||||
assert models.ResourceAccess.objects.filter(resource=room, user=admin_user).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# inactive user
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_user_is_inactive(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when the target participant account is inactive."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4(), is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "This participant account is inactive and cannot be promoted"
|
||||
}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# owner protection
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_target_is_owner(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when trying to promote a participant who is already an owner."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(resource=room, user=participant_user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "Owners already have the highest privileges and cannot be promoted"
|
||||
}
|
||||
mock_instance.update.assert_not_called()
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# LiveKit update failures
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_partial_success_when_livekit_participant_missing(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 200 with warning when participant left during the promotion.
|
||||
|
||||
The DB resource access is kept — they will have admin privileges on rejoin.
|
||||
"""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.update.side_effect = ParticipantNotFoundException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {
|
||||
"status": "success",
|
||||
"warning": "LiveKit update failed; role update persisted",
|
||||
}
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_partial_success_on_livekit_management_exception(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 200 with warning when LiveKit update fails but DB transaction succeeded."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.update.side_effect = ParticipantsManagementException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {
|
||||
"status": "success",
|
||||
"warning": "LiveKit update failed; role update persisted",
|
||||
}
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# payload validation
|
||||
# ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"participant_identity": "not-a-uuid"},
|
||||
{"participant_identity": ""},
|
||||
{"participant_identity": " "},
|
||||
{"participant_identity": None},
|
||||
{},
|
||||
],
|
||||
)
|
||||
def test_promote_participant_invalid_payload(payload):
|
||||
"""Should return 400 for invalid, empty, whitespace, null, or missing participant_identity."""
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
# ---
|
||||
# room not found
|
||||
# ---
|
||||
|
||||
|
||||
def test_promote_participant_room_not_found():
|
||||
"""Should return 404 when the room does not exist."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{uuid4()}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -28,6 +28,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -47,6 +48,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -65,6 +67,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -81,6 +84,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -97,6 +101,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -200,6 +205,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -232,7 +238,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()
|
||||
@@ -246,6 +252,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -264,7 +271,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,
|
||||
)
|
||||
@@ -297,6 +304,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -338,6 +346,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -363,7 +372,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")
|
||||
@@ -383,6 +392,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -401,7 +411,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
|
||||
):
|
||||
|
||||
@@ -3,12 +3,18 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -67,7 +73,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",
|
||||
)
|
||||
@@ -79,12 +85,14 @@ def test_api_rooms_update_members():
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators():
|
||||
"""Administrators or owners of a room should be allowed to update it."""
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators(mock_update_metadata):
|
||||
"""Should sync LiveKit metadata when both configuration and access level change."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -95,7 +103,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 +112,252 @@ 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"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": "public",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators_configuration_only(mock_update_metadata):
|
||||
"""Should sync LiveKit metadata when only configuration changes."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": "restricted",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators_access_level_only(mock_update_metadata):
|
||||
"""Should sync LiveKit metadata when only access level changes."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": "public",
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators_name_only(mock_update_metadata):
|
||||
"""Should not sync LiveKit metadata when neither configuration nor access level changes."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
name="Old name",
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"name": "New name"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
# Unrelated fields untouched
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@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},
|
||||
{"can_publish_sources": None, "everyone_can_mute": True},
|
||||
{"can_publish_sources": None, "everyone_can_mute": False},
|
||||
{"can_publish_sources": None, "everyone_can_mute": "yes"},
|
||||
{"can_publish_sources": None, "everyone_can_mute": "1"},
|
||||
],
|
||||
)
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
|
||||
"""Administrators should be allowed to set valid configurations."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")], configuration={})
|
||||
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
|
||||
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata):
|
||||
"""Should not sync LiveKit metadata when patching an already empty configuration."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")], configuration={})
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
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 == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_value", ["test", [], {}])
|
||||
def test_api_rooms_update_configuration_everyone_can_mute_wrong_type(invalid_value):
|
||||
"""everyone_can_mute 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": {"everyone_can_mute": invalid_value}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
@@ -126,3 +379,61 @@ def test_api_rooms_update_administrators_of_another():
|
||||
other_room.refresh_from_db()
|
||||
assert other_room.name == "Old name"
|
||||
assert other_room.slug == "old-name"
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
||||
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": room.access_level,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
|
||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": room.access_level,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ Test suite for generated openapi schema.
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import Client
|
||||
@@ -33,10 +34,26 @@ def test_openapi_client_schema():
|
||||
)
|
||||
assert output.getvalue() == ""
|
||||
|
||||
response = Client().get("/v1.0/swagger.json")
|
||||
response = Client().get("/api/v1.0/swagger.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
with open(
|
||||
"core/tests/swagger/swagger.json", "r", encoding="utf-8"
|
||||
) as expected_schema:
|
||||
assert response.json() == json.load(expected_schema)
|
||||
|
||||
|
||||
@patch(
|
||||
"django.contrib.staticfiles.storage.staticfiles_storage.url",
|
||||
side_effect=lambda name: f"/static/{name}",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_openapi_documentation_routes(mock_staticfiles):
|
||||
"""Swagger and ReDoc documentation should be served on canonical URLs."""
|
||||
client = Client()
|
||||
|
||||
swagger_response = client.get("/api/v1.0/swagger/")
|
||||
redoc_response = client.get("/api/v1.0/redoc/")
|
||||
|
||||
assert swagger_response.status_code == 200
|
||||
assert redoc_response.status_code == 200
|
||||
|
||||
@@ -250,6 +250,112 @@ def test_api_rooms_list_filters_by_user():
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_list_access_level_in_results():
|
||||
"""Rooms should include the correct access_level for each room."""
|
||||
user = UserFactory()
|
||||
room_trusted = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||
)
|
||||
room_restricted = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.RESTRICTED
|
||||
)
|
||||
|
||||
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
|
||||
results = {r["id"]: r for r in response.data["results"]}
|
||||
assert results[str(room_trusted.id)]["access_level"] == RoomAccessLevel.TRUSTED
|
||||
assert (
|
||||
results[str(room_restricted.id)]["access_level"] == RoomAccessLevel.RESTRICTED
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_does_not_expose_sensitive_fields():
|
||||
"""Rooms should not expose pin_code or accesses."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
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
|
||||
result = response.data["results"][0]
|
||||
assert "pin_code" not in result
|
||||
assert "accesses" not in result
|
||||
assert "livekit" not in result
|
||||
|
||||
|
||||
def test_api_rooms_list_expected_fields(settings):
|
||||
"""Rooms should expose exactly the expected fields."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = "https://example.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
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 set(response.data["results"][0].keys()) == {
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"access_level",
|
||||
"configuration",
|
||||
"telephony",
|
||||
"url",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_list_expected_fields_without_telephony(settings):
|
||||
"""Rooms shouldn't expose telephony related fields when disabled."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = "https://example.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
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 "telephony" not in set(response.data["results"][0].keys())
|
||||
|
||||
|
||||
def test_api_rooms_list_expected_fields_missing_base_url(settings):
|
||||
"""Rooms shouldn't expose URL field when the application base url is missing."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
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 "url" not in set(response.data["results"][0].keys())
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_authentication():
|
||||
"""Retrieving rooms without authentication should return 401."""
|
||||
|
||||
@@ -383,6 +489,7 @@ def test_api_rooms_retrieve_success(settings):
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"access_level": str(room.access_level),
|
||||
"configuration": room.configuration,
|
||||
"url": f"http://your-application.com/{room.slug}",
|
||||
"telephony": {
|
||||
"enabled": True,
|
||||
@@ -565,11 +672,40 @@ def test_api_rooms_create_success():
|
||||
assert "slug" in response.data
|
||||
assert "name" in response.data
|
||||
assert response.data["name"] == response.data["slug"]
|
||||
assert response.data["configuration"] == {}
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_create_with_configuration_success():
|
||||
"""Creating a room with a validated configuration should succeed."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
assert response.data["configuration"] == {"can_publish_sources": ["camera"]}
|
||||
|
||||
|
||||
def test_api_rooms_create_readonly_enforcement():
|
||||
@@ -587,7 +723,6 @@ def test_api_rooms_create_readonly_enforcement():
|
||||
"id": "fake-id",
|
||||
"slug": "fake-slug",
|
||||
"name": "fake-name",
|
||||
"access_level": "public",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -599,41 +734,150 @@ def test_api_rooms_create_readonly_enforcement():
|
||||
assert response.data["slug"] != "fake-slug"
|
||||
assert "id" in response.data
|
||||
assert response.data["name"] != "fake-name"
|
||||
assert response.data["configuration"] == {}
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_unknown_actions():
|
||||
"""Updating or deleting a room are not supported yet."""
|
||||
def test_api_rooms_create_rejects_invalid_configuration():
|
||||
"""Creating a room with unsupported configuration keys should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
token = generate_test_token(
|
||||
user,
|
||||
[
|
||||
ApplicationScope.ROOMS_RETRIEVE,
|
||||
ApplicationScope.ROOMS_DELETE,
|
||||
ApplicationScope.ROOMS_UPDATE,
|
||||
],
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{
|
||||
"configuration": {
|
||||
"unsupported_flag": True,
|
||||
}
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
assert response.status_code == 400
|
||||
assert "extra inputs are not permitted" in str(response.data).lower()
|
||||
|
||||
assert response.status_code == 405
|
||||
assert 'method "delete" not allowed.' in str(response.data).lower()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_configuration",
|
||||
[
|
||||
{"can_publish_sources": ["invalid-source"]},
|
||||
{"everyone_can_mute": "invalid-value"},
|
||||
],
|
||||
)
|
||||
def test_api_rooms_create_rejects_invalid_configuration_values(invalid_configuration):
|
||||
"""Creating a room with invalid configuration values should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"configuration": invalid_configuration},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert 'method "patch" not allowed.' in str(response.data).lower()
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_public_access_disabled_by_default():
|
||||
"""Public rooms should be disabled for the external API by default."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "public rooms are disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_public_access_enabled_with_settings(settings):
|
||||
"""Public rooms should be creatable when explicitly enabled."""
|
||||
|
||||
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_api_rooms_create_default_access_level_respects_settings(settings):
|
||||
"""Room creation should reflect the EXTERNAL_API_DEFAULT_ACCESS_LEVEL setting."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.TRUSTED
|
||||
|
||||
settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public"
|
||||
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_api_rooms_create_public_access_level_when_default_is_public(settings):
|
||||
"""Explicit public access_level is accepted when the default is already public."""
|
||||
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = False
|
||||
settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public"
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# No access_level in body — default kicks in, public room is created.
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
# Explicit access_level=public in body — still rejected.
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Test utils.build_telephony_config
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from core.utils import build_telephony_config
|
||||
|
||||
|
||||
def test_build_telephony_config_disabled(settings):
|
||||
"""Returns {"enabled": False} when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
config = build_telephony_config()
|
||||
assert config == {"enabled": False}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_valid_number(settings):
|
||||
"""Returns full config with country and international number when telephony is enabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "0123456789"
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
config = build_telephony_config()
|
||||
assert config == {
|
||||
"enabled": True,
|
||||
"default_country": "FR",
|
||||
"international_phone_number": "+33 1 23 45 67 89",
|
||||
}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_invalid_number(settings):
|
||||
"""Returns {"enabled": False} when phone number cannot be parsed."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "not-a-number"
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
config = build_telephony_config()
|
||||
assert config == {"enabled": False}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_missing_number(settings):
|
||||
"""Returns {"enabled": False} when phone number is not configured."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = ""
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
config = build_telephony_config()
|
||||
assert config == {"enabled": False}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_missing_number_warns(settings, caplog):
|
||||
"""Logs a warning when telephony is enabled but phone number is not configured."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = ""
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
build_telephony_config()
|
||||
|
||||
assert "ROOM_TELEPHONY_PHONE_NUMBER" in caplog.text
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Test utils._format_telephony_phone_number
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from core.utils import _format_telephony_phone_number
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_lru_cache():
|
||||
"""Clear the lru_cache before each test to ensure isolation."""
|
||||
_format_telephony_phone_number.cache_clear()
|
||||
yield
|
||||
_format_telephony_phone_number.cache_clear()
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_missing_raw_number():
|
||||
"""Returns (None, None) when raw_number is empty."""
|
||||
country, international = _format_telephony_phone_number("", "FR")
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_none_raw_number():
|
||||
"""Returns (None, None) when raw_number is None."""
|
||||
country, international = _format_telephony_phone_number(None, "FR")
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_missing_default_country():
|
||||
"""Returns (None, None) when default_country is empty."""
|
||||
country, international = _format_telephony_phone_number("+33123456789", "")
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_none_default_country():
|
||||
"""Returns (None, None) when default_country is None."""
|
||||
country, international = _format_telephony_phone_number("+33123456789", None)
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_both_missing():
|
||||
"""Returns (None, None) when both inputs are missing."""
|
||||
country, international = _format_telephony_phone_number(None, None)
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_invalid_number(caplog):
|
||||
"""Returns (None, None) and logs a warning when the number cannot be parsed."""
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
country, international = _format_telephony_phone_number("not-a-number", "FR")
|
||||
|
||||
assert country is None
|
||||
assert international is None
|
||||
assert "not-a-number" in caplog.text
|
||||
assert "FR" in caplog.text
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_french_number():
|
||||
"""Returns correct country and international format for a valid French number."""
|
||||
country, international = _format_telephony_phone_number("0123456789", "FR")
|
||||
assert country == "FR"
|
||||
assert international == "+33 1 23 45 67 89"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_e164_number():
|
||||
"""Returns correct result for an E.164-formatted number (no default country needed)."""
|
||||
country, international = _format_telephony_phone_number("+33123456789", "US")
|
||||
assert country == "FR"
|
||||
assert international == "+33 1 23 45 67 89"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_us_number():
|
||||
"""Returns correct country and international format for a valid US number."""
|
||||
country, international = _format_telephony_phone_number("2025550123", "US")
|
||||
assert country == "US"
|
||||
assert international == "+1 202-555-0123"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_german_number():
|
||||
"""Returns correct country and international format for a valid German number."""
|
||||
country, international = _format_telephony_phone_number("03012345678", "DE")
|
||||
assert country == "DE"
|
||||
assert international == "+49 30 12345678"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_lru_cache():
|
||||
"""Results are cached: the same inputs return the same object."""
|
||||
result1 = _format_telephony_phone_number("0123456789", "FR")
|
||||
result2 = _format_telephony_phone_number("0123456789", "FR")
|
||||
assert result1 is result2
|
||||
# pylint: disable=no-value-for-parameter
|
||||
cache_info = _format_telephony_phone_number.cache_info()
|
||||
assert cache_info.hits >= 1
|
||||
@@ -12,6 +12,7 @@ import mimetypes
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -22,6 +23,7 @@ import aiohttp
|
||||
import boto3
|
||||
import botocore
|
||||
import magic
|
||||
import phonenumbers
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
@@ -455,3 +457,58 @@ def generate_upload_policy(file):
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _format_telephony_phone_number(raw_number, default_country):
|
||||
"""Parse a configured phone number and return (country, international_format).
|
||||
|
||||
Returns (None, None) if the inputs are missing or the number cannot be
|
||||
parsed. Logs a warning on parse failure so operators see the misconfiguration.
|
||||
"""
|
||||
if not raw_number or not default_country:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
parsed = phonenumbers.parse(raw_number, default_country)
|
||||
except phonenumbers.NumberParseException:
|
||||
logger.warning(
|
||||
"ROOM_TELEPHONY_PHONE_NUMBER %r is not a valid phone number for "
|
||||
"default country %r; telephony block will be returned without "
|
||||
"formatted number.",
|
||||
raw_number,
|
||||
default_country,
|
||||
)
|
||||
return None, None
|
||||
|
||||
country = phonenumbers.region_code_for_number(parsed)
|
||||
international = phonenumbers.format_number(
|
||||
parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL
|
||||
)
|
||||
return country, international
|
||||
|
||||
|
||||
def build_telephony_config():
|
||||
"""Build the telephony block of the frontend configuration."""
|
||||
if not settings.ROOM_TELEPHONY_ENABLED:
|
||||
return {"enabled": False}
|
||||
|
||||
country, international = _format_telephony_phone_number(
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER,
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
)
|
||||
|
||||
if international is None:
|
||||
logger.warning(
|
||||
"Telephony is enabled but ROOM_TELEPHONY_PHONE_NUMBER %r with "
|
||||
"default country %r could not be formatted; telephony will be disabled.",
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER,
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
)
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -817,6 +855,11 @@ class Base(Configuration):
|
||||
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(
|
||||
@@ -865,6 +908,18 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
False,
|
||||
environ_name="EXTERNAL_API_ALLOW_PUBLIC_ACCESS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
EXTERNAL_API_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"trusted",
|
||||
environ_name="EXTERNAL_API_DEFAULT_ACCESS_LEVEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Allows third-party platforms to create users with email-only identification.
|
||||
# Required for external integrations, but fragile due to deferred user reconciliation
|
||||
# on sub. Enable it with care /!\
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path, re_path
|
||||
from django.urls import include, path
|
||||
|
||||
from drf_spectacular.views import (
|
||||
SpectacularJSONAPIView,
|
||||
@@ -29,7 +29,7 @@ if settings.DEBUG:
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
f"{settings.API_VERSION}/swagger.json",
|
||||
f"api/{settings.API_VERSION}/swagger.json",
|
||||
SpectacularJSONAPIView.as_view(
|
||||
api_version=settings.API_VERSION,
|
||||
urlconf="core.urls",
|
||||
@@ -37,12 +37,12 @@ if settings.USE_SWAGGER or settings.DEBUG:
|
||||
name="client-api-schema",
|
||||
),
|
||||
path(
|
||||
f"{settings.API_VERSION}//swagger/",
|
||||
f"api/{settings.API_VERSION}/swagger/",
|
||||
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
|
||||
name="swagger-ui-schema",
|
||||
),
|
||||
re_path(
|
||||
f"{settings.API_VERSION}//redoc/",
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="client-api-schema"),
|
||||
name="redoc-schema",
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.15.0"
|
||||
version = "1.17.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"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",
|
||||
@@ -61,6 +61,8 @@ dependencies = [
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.4",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.30",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -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]]
|
||||
@@ -1173,7 +1173,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.15.0"
|
||||
version = "1.17.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1204,6 +1204,7 @@ dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -1212,6 +1213,7 @@ dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "urllib3" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
@@ -1243,7 +1245,7 @@ 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" },
|
||||
@@ -1265,6 +1267,7 @@ requires-dist = [
|
||||
{ name = "markdown", specifier = "==3.10.2" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.30" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pyjwt", specifier = "==2.12.1" },
|
||||
@@ -1273,6 +1276,7 @@ requires-dist = [
|
||||
{ name = "redis", specifier = "==5.2.1" },
|
||||
{ name = "requests", specifier = "==2.33.0" },
|
||||
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
||||
{ name = "urllib3", specifier = "==2.7.0" },
|
||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||
]
|
||||
|
||||
@@ -1428,6 +1432,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phonenumbers"
|
||||
version = "9.0.30"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/f1/249f843f4107c6a6ed17e5ece17620d75e532c2a355106e26d889a0c72c7/phonenumbers-9.0.30.tar.gz", hash = "sha256:d42d232ccde69c1af1bb5916a7e46f4edbcc72975b02759830f4ea1fba7b00c9", size = 2306521, upload-time = "2026-05-07T10:20:38.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/22/e4442aabea04daf16fda50d89bce2ff585e44f204089986b2cc6679cae10/phonenumbers-9.0.30-py2.py3-none-any.whl", hash = "sha256:e0890d4cda206ef6ac18ef07e8f3ab225c31c7edce237ac870b4729d4c1d2520", size = 2595222, upload-time = "2026-05-07T10:20:35.387Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
@@ -2260,11 +2273,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module '@fontsource-variable/lexend' {}
|
||||
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
|
||||
declare module '@fontsource/opendyslexic' {}
|
||||
@@ -6,22 +6,6 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.15.0",
|
||||
"version": "1.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
"build": "panda codegen && tsc -b && vite build",
|
||||
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||
@@ -13,8 +14,9 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
@@ -32,7 +34,6 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.17.1",
|
||||
"posthog-js": "1.342.1",
|
||||
"react": "18.3.1",
|
||||
@@ -44,7 +45,7 @@
|
||||
"wouter": "3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "1.8.2",
|
||||
"@pandacss/dev": "1.11.1",
|
||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@types/humanize-duration": "3.27.4",
|
||||
@@ -59,10 +60,12 @@
|
||||
"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.14",
|
||||
"prettier": "3.8.1",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ const config: Config = {
|
||||
'room-side-panel-margin': { value: '1.5rem' },
|
||||
'room-control-bar': { value: '80px' },
|
||||
'room-reaction-toolbar-height': { value: '42px' },
|
||||
'tooltip-spacing': { value: '8px' },
|
||||
},
|
||||
spacing,
|
||||
}),
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,3 @@
|
||||
import '@livekit/components-styles'
|
||||
import '@/styles/index.css'
|
||||
import { Suspense } from 'react'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
@@ -14,12 +13,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}>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import type { Track } from 'livekit-client'
|
||||
type Source = Track.Source
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -42,7 +44,7 @@ export interface ApiConfig {
|
||||
}
|
||||
telephony: {
|
||||
enabled: boolean
|
||||
phone_number?: string
|
||||
international_phone_number?: string
|
||||
default_country?: string
|
||||
}
|
||||
manifest_link?: string
|
||||
@@ -50,7 +52,7 @@ export interface ApiConfig {
|
||||
url: string
|
||||
force_wss_protocol: boolean
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
default_sources: string[]
|
||||
default_sources: Source[]
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 178 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q17-72 85-137t145-65q33 0 56.5 23.5T520-716v242l64-62 56 56-160 160-160-160 56-56 64 62v-242q-76 14-118 73.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h480q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-48-22-89.5T600-680v-93q74 35 117 103.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H260Zm220-358Z"/></svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M325-111.5q-73-31.5-127.5-86t-86-127.5Q80-398 80-480.5t31.5-155q31.5-72.5 86-127t127.5-86Q398-880 480.5-880t155 31.5q72.5 31.5 127 86t86 127Q880-563 880-480.5T848.5-325q-31.5 73-86 127.5t-127 86Q563-80 480.5-80T325-111.5ZM480-162q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>
|
||||
|
After Width: | Height: | Size: 996 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120v-80h280v-560H480v-80h280q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H480Zm-80-160-55-58 102-102H120v-80h327L345-622l55-58 200 200-200 200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 278 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm320-280L160-640v400h640v-400L480-440Zm0-80 320-200H160l320 200ZM160-640v-80 480-400Z"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M565-395q35-35 35-85t-35-85q-35-35-85-35t-85 35q-35 35-35 85t35 85q35 35 85 35t85-35ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M80-40v-80h800v80H80Zm80-120v-240q-33-54-51-114.5T91-638q0-61 15.5-120T143-874q8-21 26-33.5t40-12.5q31 0 53 21t18 50l-11 91q-6 48 8.5 91t43.5 75.5q29 32.5 70 52t89 19.5q60 0 120.5 12.5T706-472q45 23 69.5 58.5T800-326v166H160Zm80-80h480v-86q0-24-12-42.5T674-398q-41-20-95-31t-99-11q-66 0-122.5-27t-96-72.5Q222-585 202-644.5T190-768q-10 30-14.5 64t-4.5 66q0 58 20.5 111.5T240-422v182Zm127-367q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47Zm169.5-56.5Q560-687 560-720t-23.5-56.5Q513-800 480-800t-56.5 23.5Q400-753 400-720t23.5 56.5Q447-640 480-640t56.5-23.5ZM320-160v-37q0-67 46.5-115T480-360h160v80H480q-34 0-57 24.5T400-197v37h-80Zm160-80Zm0-480Z"/></svg>
|
||||
|
After Width: | Height: | Size: 808 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M158-242q-37-50-57.5-110.5T80-480q0-67 20-127t57-110l58 57q-26 38-40.5 83.5T160-480q0 51 14.5 97t40.5 84l-57 57ZM480-80q-67 0-127-20t-110-57l57-58q38 26 83.5 40.5T480-160q51 0 96.5-14.5T660-215l57 58q-50 37-110 57T480-80Zm322-162-57-57q26-38 40.5-84t14.5-97q0-51-14.5-96.5T745-660l58-57q37 50 57 110t20 127q0 67-20.5 127.5T802-242ZM299-745l-57-57q50-37 110.5-57.5T480-880q68 0 128 20.5T718-802l-57 57q-38-26-84-40.5T480-800q-51 0-97 14.5T299-745Zm181 465q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280Z"/></svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M680-560q-33 0-56.5-23T600-640v-160q0-34 23.5-57t56.5-23q34 0 57 23t23 57v160q0 34-23 57t-57 23ZM200-80q-33 0-56.5-23.5T120-160v-640q0-33 23.5-56.5T200-880h320v80H200v640h440v-80h80v80q0 33-23.5 56.5T640-80H200Zm80-160v-80h280v80H280Zm0-120v-80h200v80H280Zm440 40h-80v-104q-77-14-128.5-74.5T460-640h80q0 58 41 99t99 41q59 0 99.5-41t40.5-99h80q0 81-51 141.5T720-424v104Z"/></svg>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -1,20 +1,14 @@
|
||||
import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth/api/useSyncUserPreferencesWithBackend'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
useSyncUserPreferencesWithBackend()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
support = {},
|
||||
silence_livekit_debug_logs = false,
|
||||
custom_css_url = '',
|
||||
} = data ?? {}
|
||||
const { analytics = {}, support = {}, custom_css_url = '' } = data ?? {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
@@ -29,7 +23,5 @@ export const AppInitialization = () => {
|
||||
}
|
||||
}, [custom_css_url])
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LinkButton } from '@/primitives'
|
||||
import { authUrl } from '@/features/auth'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { ProConnectButton } from './ProConnectButton'
|
||||
import { authUrl } from '@/features/auth/utils/authUrl'
|
||||
|
||||
type LoginButtonProps = {
|
||||
proConnectHint?: boolean // Hide hint in layouts where space doesn't allow it.
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import posthog from 'posthog-js'
|
||||
import { ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { type PostHog } from 'posthog-js'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
if (posthog._isIdentified()) return
|
||||
const { id, email } = data
|
||||
posthog.identify(id, { email })
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
|
||||
export const terminateAnalyticsSession = () => {
|
||||
if (!posthog._isIdentified()) return
|
||||
posthog.reset()
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
getPosthog().then((ph) => {
|
||||
if (ph._isIdentified()) return
|
||||
const { id, email } = data
|
||||
ph.identify(id, { email })
|
||||
})
|
||||
}
|
||||
|
||||
export const terminateAnalyticsSession = async () => {
|
||||
const ph = await getPosthog()
|
||||
if (!ph._isIdentified()) return
|
||||
ph.reset()
|
||||
}
|
||||
|
||||
export type useAnalyticsProps = {
|
||||
@@ -22,18 +33,26 @@ export type useAnalyticsProps = {
|
||||
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !host || isDisabled) return
|
||||
if (posthog.__loaded) return
|
||||
posthog.init(id, {
|
||||
api_host: host,
|
||||
person_profiles: 'always',
|
||||
getPosthog().then((ph) => {
|
||||
if (ph.__loaded) return
|
||||
ph.init(id, { api_host: host, person_profiles: 'always' })
|
||||
})
|
||||
}, [id, host, isDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
startAnalyticsSession(user)
|
||||
}, [user])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
posthog.capture('$pageview')
|
||||
getPosthog().then((ph) => {
|
||||
ph.capture('$pageview')
|
||||
})
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
|
||||
@@ -2,16 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
startAnalyticsSession,
|
||||
terminateAnalyticsSession,
|
||||
} from '@/features/analytics/hooks/useAnalytics'
|
||||
import {
|
||||
initializeSupportSession,
|
||||
terminateSupportSession,
|
||||
} from '@/features/support/hooks/useSupport'
|
||||
import { logoutUrl } from '../utils/logoutUrl'
|
||||
import { useMemo } from 'react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
/**
|
||||
@@ -45,19 +36,6 @@ export const useUser = (
|
||||
enabled: !isConfigLoading,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.data) {
|
||||
startAnalyticsSession(query.data)
|
||||
initializeSupportSession(query.data)
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
const logout = () => {
|
||||
terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
|
||||
const isLoggedIn =
|
||||
query.status === 'success' ? query.data !== false : undefined
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
@@ -67,6 +45,5 @@ export const useUser = (
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
isLoading: query.isLoading,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useUser } from '../api/useUser'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useUser } from './api/useUser'
|
||||
export { useSyncUserPreferencesWithBackend } from './api/useSyncUserPreferencesWithBackend'
|
||||
export { authUrl } from './utils/authUrl'
|
||||
export { UserAware } from './components/UserAware'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiUrl } from '@/api/apiUrl'
|
||||
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
|
||||
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
|
||||
const logoutUrl = () => {
|
||||
return apiUrl('/logout')
|
||||
}
|
||||
|
||||
export const logout = async () => {
|
||||
await terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { apiUrl } from '@/api/apiUrl'
|
||||
|
||||
export const logoutUrl = () => {
|
||||
return apiUrl('/logout')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authUrl } from '@/features/auth'
|
||||
import { authUrl } from './authUrl'
|
||||
|
||||
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ApiFileType,
|
||||
ApiFileUploadState,
|
||||
} from '@/features/files/api/types.ts'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
type ListFilesResponse = {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { loadUserChoices } from '@livekit/components-core'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = loadUserChoices()
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then(setLaterRoom)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Home as HomeRoute } from './routes/Home'
|
||||
@@ -1,24 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { DialogTrigger } from 'react-aria-components'
|
||||
import { Button } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { UserAware } from '@/features/auth/components/UserAware'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { IntroSlider } from '../components/IntroSlider'
|
||||
import { MoreLink } from '../components/MoreLink'
|
||||
import { CreateMeetingMenu } from '../components/CreateMeetingMenu'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
@@ -146,18 +141,11 @@ const IntroText = styled('div', {
|
||||
},
|
||||
})
|
||||
|
||||
export const Home = () => {
|
||||
const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,45 +188,7 @@ export const Home = () => {
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
<CreateMeetingMenu />
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
)}
|
||||
@@ -264,11 +214,9 @@ export const Home = () => {
|
||||
<IntroSlider />
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
|
||||
@@ -12,7 +12,7 @@ const controlBarRegion = cva({
|
||||
variants: {
|
||||
mobile: {
|
||||
true: {
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'center',
|
||||
width: '330px',
|
||||
},
|
||||
},
|
||||
@@ -22,6 +22,8 @@ const controlBarRegion = cva({
|
||||
},
|
||||
})
|
||||
|
||||
export const CONTROL_BAR_REGION_ID = 'control-bar-region'
|
||||
|
||||
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
|
||||
RecipeVariantProps<typeof controlBarRegion>
|
||||
|
||||
@@ -34,6 +36,7 @@ export function ControlBarRegion({
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
id={CONTROL_BAR_REGION_ID}
|
||||
aria-label={t('controls.region')}
|
||||
className={controlBarRegion({ mobile })}
|
||||
{...props}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { H, P, A, Italic, Ul } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const AccessibilityRoute = () => {
|
||||
const AccessibilityRoute = () => {
|
||||
const { t } = useTranslation('accessibility', { keyPrefix: 'accessibility' })
|
||||
|
||||
return (
|
||||
@@ -78,3 +78,5 @@ export const AccessibilityRoute = () => {
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessibilityRoute
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const LegalTermsRoute = () => {
|
||||
const LegalTermsRoute = () => {
|
||||
const { t } = useTranslation('legals')
|
||||
|
||||
const indentedStyle = css({
|
||||
@@ -72,3 +72,5 @@ export const LegalTermsRoute = () => {
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default LegalTermsRoute
|
||||
|
||||
@@ -12,7 +12,7 @@ const ensureArray = (value: any) => {
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export const TermsOfServiceRoute = () => {
|
||||
const TermsOfServiceRoute = () => {
|
||||
const { t } = useTranslation('termsOfService')
|
||||
|
||||
return (
|
||||
@@ -199,3 +199,5 @@ export const TermsOfServiceRoute = () => {
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default TermsOfServiceRoute
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { type ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Div } from '@/primitives'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { decodeNotificationDataReceived } from './utils'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
import { toastQueue } from './components/ToastProvider'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||
import { NotificationProvider } from './NotificationProvider'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -233,10 +232,5 @@ export const MainNotificationToast = () => {
|
||||
// the 'notifications' namespace might not be loaded yet
|
||||
useTranslation(['notifications'])
|
||||
|
||||
return (
|
||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
</Div>
|
||||
)
|
||||
return <NotificationProvider />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationType } from './NotificationType'
|
||||
import type { NotificationType } from './NotificationType'
|
||||
|
||||
export interface NotificationPayload {
|
||||
type: NotificationType
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Div } from '@/primitives'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
|
||||
export const NotificationProvider = ({
|
||||
bottom = 0,
|
||||
right = 5,
|
||||
}: {
|
||||
bottom?: number
|
||||
right?: number
|
||||
}) => (
|
||||
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
</Div>
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ToastState } from '@react-stately/toast'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRef } from 'react'
|
||||
import { ToastData } from './ToastProvider'
|
||||
import type { ToastState } from '@react-stately/toast'
|
||||
import type { ToastData } from './ToastProvider'
|
||||
import type { QueuedToast } from '@react-stately/toast'
|
||||
|
||||
export const StyledToastContainer = styled('div', {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
@@ -6,7 +6,7 @@ import Source = Track.Source
|
||||
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||