mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a31253c72e | |||
| 029feea486 | |||
| 0bc554e331 | |||
| 627867d89e | |||
| d8add71d74 | |||
| a345b5cfe0 | |||
| e9184f3af2 | |||
| 617beb3340 | |||
| d7ab5f4f1f | |||
| 280ebdfe7f | |||
| 0caecbdfba | |||
| d77b187565 | |||
| 8cbcad7645 | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 |
@@ -0,0 +1,29 @@
|
||||
# /!\
|
||||
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||
name: Security Review
|
||||
|
||||
permissions:
|
||||
pull-requests: write # Needed for leaving PR comments
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||
with:
|
||||
comment-pr: true
|
||||
exclude-directories: docs,gitlint,LICENSES,bin
|
||||
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||
+1
-5
@@ -15,9 +15,6 @@ and this project adheres to
|
||||
- ✨(frontend) allow promoting authenticated participants
|
||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||
- ✨(frontend) let users set default configuration for generated links
|
||||
- ✨(frontend) expose media state to external gateways
|
||||
- ✨(backend) push recordings to the owner's Drive (POC)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -32,7 +29,7 @@ and this project adheres to
|
||||
- 💄(frontend) improve participant name rendering in the list
|
||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
@@ -41,7 +38,6 @@ and this project adheres to
|
||||
- 🐛(backend) allow any string as sub in the API serializer
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
env.d/development/summary \
|
||||
env.d/development/kube-secret \
|
||||
env.d/development/multi_user_transcriber \
|
||||
@@ -93,7 +92,6 @@ bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
create-docker-network \
|
||||
build \
|
||||
migrate \
|
||||
demo \
|
||||
@@ -127,16 +125,11 @@ down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
|
||||
create-docker-network: ## create the shared lasuite-network if it doesn't exist
|
||||
@docker network create lasuite-network || true
|
||||
.PHONY: create-docker-network
|
||||
|
||||
logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(MAKE) create-docker-network
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up -d livekit
|
||||
@@ -294,9 +287,6 @@ env.d/development/common:
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
env.d/development/summary:
|
||||
cp -n env.d/development/summary.dist env.d/development/summary
|
||||
|
||||
|
||||
+10
-48
@@ -14,9 +14,6 @@ services:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -36,10 +33,6 @@ services:
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- meet-minio
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
@@ -99,7 +92,6 @@ services:
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -116,9 +108,6 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
app:
|
||||
build:
|
||||
@@ -158,7 +147,7 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- dex
|
||||
- app-dev
|
||||
networks:
|
||||
- resource-server
|
||||
@@ -198,40 +187,16 @@ services:
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.3.2
|
||||
# OIDC provider for the development stack. Dex uses in-memory storage, so it
|
||||
# needs no database and no volume: restarting it rotates the signing keys and
|
||||
# drops every active session, which is fine locally.
|
||||
dex:
|
||||
image: dexidp/dex:v2.45.1
|
||||
command: ["dex", "serve", "/etc/dex/config.yaml"]
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy-headers=xforwarded
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: meet
|
||||
KC_DB_SCHEMA: public
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
- ./docker/auth/dex.yaml:/etc/dex/config.yaml:ro
|
||||
expose:
|
||||
- "5556"
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server
|
||||
@@ -348,6 +313,3 @@ services:
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Dex configuration for the local development stack.
|
||||
#
|
||||
# This file replaces the former Keycloak "meet" realm (docker/auth/realm.json).
|
||||
# The client and the users below are a one-to-one port of that realm.
|
||||
#
|
||||
# Storage is in-memory on purpose: no database container, no volume, ~30 MB of
|
||||
# RAM instead of the Keycloak + PostgreSQL pair. The trade-off is that
|
||||
# restarting the `dex` service rotates the signing keys and drops every active
|
||||
# session, so you have to log in again.
|
||||
|
||||
# Must match OIDC_OP_URL in env.d/development/common. Dex serves all of its
|
||||
# endpoints under the path component of the issuer, i.e. /dex/auth, /dex/token,
|
||||
# /dex/keys, /dex/userinfo and /dex/.well-known/openid-configuration.
|
||||
issuer: http://localhost:8083/dex
|
||||
|
||||
storage:
|
||||
type: memory
|
||||
|
||||
web:
|
||||
http: 0.0.0.0:5556
|
||||
allowedOrigins:
|
||||
- http://localhost:3000
|
||||
- http://localhost:8071
|
||||
|
||||
logger:
|
||||
level: info
|
||||
format: text
|
||||
|
||||
oauth2:
|
||||
# Logging in implies authorization: no consent screen, as with the realm.
|
||||
skipApprovalScreen: true
|
||||
|
||||
expiry:
|
||||
idTokens: 24h
|
||||
signingKeys: 6h
|
||||
|
||||
staticClients:
|
||||
- id: meet
|
||||
name: Meet
|
||||
secret: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
# Dex does not support wildcards: every callback URL must be listed
|
||||
# explicitly. The path is the one exposed by mozilla-django-oidc through
|
||||
# lasuite.oidc_login, mounted under api/<version>/ by core.urls.
|
||||
redirectURIs:
|
||||
- http://localhost:3000/api/v1.0/callback/
|
||||
- http://localhost:3200/api/v1.0/callback/
|
||||
- http://localhost:8070/api/v1.0/callback/
|
||||
- http://localhost:8071/api/v1.0/callback/
|
||||
- http://localhost:8088/api/v1.0/callback/
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
# Dex's local password database authenticates on the *email address*, not on
|
||||
# the username, so the login is now "meet@meet.world" (password unchanged).
|
||||
#
|
||||
# Hashes are bcrypt with cost 10, the minimum dex accepts. To add a user:
|
||||
# htpasswd -bnBC 10 "" <password> | tr -d ':\n'
|
||||
staticPasswords:
|
||||
- email: meet@meet.world
|
||||
hash: "$2b$10$qVCVTnaF67S/7a.pQM4djOgpj61FxD/yz6LoiQdtX0TKISelAfZxC"
|
||||
username: meet
|
||||
name: John Doe
|
||||
preferredUsername: John
|
||||
userID: 4ad6106f-a64f-43eb-ad0e-380d2cad9a9d
|
||||
groups:
|
||||
- user
|
||||
|
||||
- email: user@chromium.e2e
|
||||
hash: "$2b$10$4Rs3Jd/Q23RM09g7c1Z/yeGmEjoAYlMKXDBkkjERaRDlz0Doiwl2q"
|
||||
username: user-e2e-chromium
|
||||
name: E2E Chromium
|
||||
preferredUsername: E2E
|
||||
userID: 1cd83dfc-153f-4987-b8a6-a2ac72d39122
|
||||
groups:
|
||||
- user
|
||||
|
||||
- email: user@webkit.e2e
|
||||
hash: "$2b$10$D50UlVVMA7qWlB.Pw8P02eMJpo8qfwWuGiA63IeTqq/3mAE7RyH3m"
|
||||
username: user-e2e-webkit
|
||||
name: E2E Webkit
|
||||
preferredUsername: E2E
|
||||
userID: 9b9bd390-a6e5-42f8-a06d-9a11ede7bb8c
|
||||
groups:
|
||||
- user
|
||||
|
||||
- email: user@firefox.e2e
|
||||
hash: "$2b$10$0D8WW7.KXMkzSY2b9JhwYeIM3WkTPQCwGd36/G3TZ/HHh4ObCVRga"
|
||||
username: user-e2e-firefox
|
||||
name: E2E Firefox
|
||||
preferredUsername: E2E
|
||||
userID: ec3e8750-7629-42f1-a0c3-6e23968a2fba
|
||||
groups:
|
||||
- user
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_pass http://dex:5556;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
version: '3'
|
||||
|
||||
# You can add any necessary service here that will join the same docker network
|
||||
# sharing keycloak. Services added to the 'meet_resource-server' network will be
|
||||
# able to communicate with keycloak and the backend on that network.
|
||||
# sharing the OIDC provider. Services added to the 'meet_resource-server'
|
||||
# network will be able to communicate with dex (through nginx) and the backend
|
||||
# on that network.
|
||||
services:
|
||||
# busybox service is only used for testing purposes. It provides curl to test
|
||||
# connectivity to the backend and keycloak services. Replace this with your
|
||||
# relevant application services that need to communicate with keycloak.
|
||||
# connectivity to the backend and the OIDC provider. Replace this with your
|
||||
# relevant application services that need to communicate with them.
|
||||
busybox:
|
||||
image: alpine:latest
|
||||
privileged: true
|
||||
|
||||
@@ -71,8 +71,12 @@ $ make bootstrap FLUSH_ARGS='--no-input'
|
||||
|
||||
2. Access the project:
|
||||
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
||||
- username: meet
|
||||
- email: meet@meet.world
|
||||
- password: meet
|
||||
|
||||
Authentication is handled by [dex](https://dexidp.io/), configured in
|
||||
`docker/auth/dex.yaml`. It logs you in by email address, and its storage is
|
||||
in-memory: restarting the `dex` container logs everyone out.
|
||||
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
|
||||
|
||||
---
|
||||
|
||||
@@ -126,61 +126,6 @@ RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
## Push recordings to Drive
|
||||
|
||||
Once a recording is over, it can be pushed (streamed) to the user's [Drive](https://github.com/suitenumerique/drive), in addition to staying in the object storage.
|
||||
|
||||
Drive is called as a resource server, following its
|
||||
[resource server documentation](https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md):
|
||||
|
||||
|
||||
### Special requirements
|
||||
|
||||
- Drive configured as an OIDC resource server, accepting Meet's audience
|
||||
(`OIDC_RS_ALLOWED_AUDIENCES` must contain Meet's client id), with the `items`
|
||||
endpoint allowing the `list`, `children` and `upload_ended` actions.
|
||||
- `OIDC_STORE_ACCESS_TOKEN` enabled on Meet, along with
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY`, the Fernet key encrypting the stored token.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a proof of concept: the access token is captured when the recording
|
||||
> starts and assumed to still be valid when the recording ends. Long recordings
|
||||
> may therefore fail to be pushed. Exchanging it for a long-lived, narrowly
|
||||
> scoped token is the intended follow-up.
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RECORDING_PUSH_TO_DRIVE_ENABLED** | Boolean | `False` | Enable pushing recordings to the owner's Drive. |
|
||||
| **DRIVE_API_BASE_URL** | String | `None` | Base URL of Drive's external API, e.g. `https://fichiers.numerique.gouv.fr/external_api/v1.0`. |
|
||||
| **RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS** | Integer | `3600` | Lifetime of the signed URL the worker downloads the recording from. |
|
||||
| **OIDC_STORE_ACCESS_TOKEN** | Boolean | `False` | Keep the user's access token in the session, required to call Drive on their behalf. |
|
||||
| **OIDC_STORE_REFRESH_TOKEN_KEY** | Secret/File | `None` | Fernet key encrypting OIDC tokens at rest. Generate one with `Fernet.generate_key()`. |
|
||||
| **DRIVE_UPLOAD_STORAGE_NETLOC** | String | `None` | Development only: `host:port` to reach Drive's object storage at, when the domain Drive signs its upload URLs with only resolves from a browser. |
|
||||
|
||||
### Local development
|
||||
|
||||
Meet and Drive run as two separate compose projects, joined by the external
|
||||
`lasuite-network` (`make create-docker-network`). Meet's backend containers reach
|
||||
Drive's nginx at `drive-nginx:8083` and its object storage at `drive-minio:9000`.
|
||||
|
||||
On the Drive side:
|
||||
|
||||
```bash
|
||||
OIDC_RESOURCE_SERVER_ENABLED=True
|
||||
OIDC_RS_CLIENT_ID=drive
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_AUDIENCE_CLAIM=client_id
|
||||
OIDC_RS_ALLOWED_AUDIENCES=meet
|
||||
```
|
||||
|
||||
`DRIVE_UPLOAD_STORAGE_NETLOC` is needed because Drive signs its upload URLs
|
||||
with `localhost:9100`, which does not resolve from Meet's containers. The
|
||||
presigned signature covers the `Host` header, so the backend keeps announcing the
|
||||
signed host and only swaps the address it connects to.
|
||||
|
||||
|
||||
## LiveKit Egress
|
||||
|
||||
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -24,35 +24,42 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://meet-minio:9000
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
||||
OIDC_OP_URL=http://localhost:8083/realms/meet
|
||||
# Provider is dex (docker/auth/dex.yaml), served behind nginx on port 8083.
|
||||
# Endpoints reached by the browser use localhost, the ones called server-side
|
||||
# by the backend use the nginx service name.
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/dex/keys
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/dex/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/dex/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/dex/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/dex/token/introspect
|
||||
OIDC_OP_URL=http://localhost:8083/dex
|
||||
|
||||
OIDC_RP_CLIENT_ID=meet
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
# "profile" is required: dex only emits the name claims under that scope.
|
||||
OIDC_RP_SCOPES="openid email profile"
|
||||
|
||||
# Dex exposes the display name through the standard "name" and
|
||||
# "preferred_username" claims and never emits given_name/family_name.
|
||||
OIDC_USERINFO_FULLNAME_FIELDS=name
|
||||
OIDC_USERINFO_SHORTNAME_FIELD=preferred_username
|
||||
|
||||
LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# The key below encrypts OIDC tokens at rest.
|
||||
OIDC_STORE_ACCESS_TOKEN=True
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY=
|
||||
# Dex has no notion of ACR, the eIDAS level requested from ProConnect in
|
||||
# production is meaningless here and would just be ignored.
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={}
|
||||
|
||||
OIDC_RS_CLIENT_ID=meet
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -74,12 +81,6 @@ SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Push recordings to Drive
|
||||
# DRIVE_UPLOAD_STORAGE_NETLOC is a development-only workaround.
|
||||
RECORDING_PUSH_TO_DRIVE_ENABLED=True
|
||||
DRIVE_API_BASE_URL=http://drive-app-dev:8000/external_api/v1.0
|
||||
DRIVE_UPLOAD_STORAGE_NETLOC=drive-minio:9000
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=meet
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=kc_postgresql
|
||||
DB_NAME=keycloak
|
||||
DB_USER=meet
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5433
|
||||
@@ -2,7 +2,7 @@ LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
AWS_S3_ENDPOINT_URL=meet-minio:9000
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
|
||||
@@ -2,7 +2,7 @@ APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="meet-minio:9000"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
AWS_S3_ACCESS_KEY_ID="meet"
|
||||
|
||||
@@ -61,9 +61,6 @@ def get_frontend_configuration(request):
|
||||
],
|
||||
},
|
||||
"telephony": build_telephony_config(),
|
||||
"resource": {
|
||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
},
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
|
||||
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_room_access_level",
|
||||
"default_room_configuration",
|
||||
]
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
def validate_default_room_configuration(self, value):
|
||||
"""Validate the default 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
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
@@ -308,27 +308,8 @@ class RoomViewSet(
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room.
|
||||
|
||||
Apply the user's default room preferences (access level and configuration)
|
||||
unless the request explicitly provides its own values.
|
||||
"""
|
||||
user = self.request.user
|
||||
save_kwargs = {}
|
||||
|
||||
if (
|
||||
"access_level" not in serializer.validated_data
|
||||
and user.default_room_access_level not in (None, "")
|
||||
):
|
||||
save_kwargs["access_level"] = user.default_room_access_level
|
||||
|
||||
user_default_configuration = user.default_room_configuration
|
||||
if not serializer.validated_data.get(
|
||||
"configuration"
|
||||
) and user_default_configuration not in (None, {}):
|
||||
save_kwargs["configuration"] = user.default_room_configuration
|
||||
|
||||
room = serializer.save(**save_kwargs)
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
user=self.request.user,
|
||||
@@ -383,37 +364,6 @@ class RoomViewSet(
|
||||
room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _store_drive_credentials(request, recording):
|
||||
"""Keep the OIDC access token needed to push the recording to Drive later.
|
||||
|
||||
Pushing happens long after this request, when the egress is over and the
|
||||
user may be gone, so the token has to be stored.
|
||||
|
||||
POC limitation: we assume the token is still valid by then. The target
|
||||
design is a token exchange performed here, to get a long-lived
|
||||
token narrowly scoped to that upload.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
if recording.mode != models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
# Only videos are pushed to Drive, no need for a token otherwise.
|
||||
return
|
||||
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
|
||||
if not access_token:
|
||||
logger.warning(
|
||||
"No OIDC access token in session, recording %s will not be pushed "
|
||||
"to Drive. Is OIDC_STORE_ACCESS_TOKEN enabled?",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
recording.set_owner_access_token(access_token)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -449,7 +399,6 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
self._store_drive_credentials(request, recording)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-08-03 13:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='default_room_access_level',
|
||||
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='default_room_configuration',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
|
||||
),
|
||||
]
|
||||
@@ -189,25 +189,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
default=settings.TIME_ZONE,
|
||||
help_text=_("The timezone in which the user wants to see times."),
|
||||
)
|
||||
default_room_access_level = models.CharField(
|
||||
max_length=50,
|
||||
choices=RoomAccessLevel.choices,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("default room access level"),
|
||||
help_text=_(
|
||||
"Access level applied by default to new rooms created by this user. "
|
||||
"When empty, the instance default is used."
|
||||
),
|
||||
)
|
||||
default_room_configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name=_("default room configuration"),
|
||||
help_text=_(
|
||||
"Configurations applied by default to new rooms created by this user."
|
||||
),
|
||||
)
|
||||
is_device = models.BooleanField(
|
||||
_("device"),
|
||||
default=False,
|
||||
@@ -639,17 +620,6 @@ class Recording(BaseModel):
|
||||
verbose_name=_("External Process ID"),
|
||||
help_text=_("ID of the external process associated with the recording."),
|
||||
)
|
||||
owner_access_token = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name=_("Owner access token"),
|
||||
help_text=_(
|
||||
"Encrypted OIDC access token of the user who started the recording, "
|
||||
"used to push the recording to their Drive on their behalf. "
|
||||
"Dropped as soon as the push has been attempted."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -752,39 +722,6 @@ class Recording(BaseModel):
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
def set_owner_access_token(self, access_token: str) -> None:
|
||||
"""Store the OIDC access token of the user who started the recording.
|
||||
|
||||
It is stored encrypted, and only long enough for the worker to push the
|
||||
recording to that user's Drive once the recording is over.
|
||||
"""
|
||||
|
||||
self.owner_access_token = utils.encrypt_secret(access_token)
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
def get_owner_access_token(self) -> Optional[str]:
|
||||
"""Return the stored OIDC access token, or None if there is none left."""
|
||||
|
||||
if not self.owner_access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
return utils.decrypt_secret(self.owner_access_token)
|
||||
except utils.SecretDecryptionError:
|
||||
logger.exception(
|
||||
"Could not decrypt the access token of recording %s", self.id
|
||||
)
|
||||
return None
|
||||
|
||||
def clear_owner_access_token(self) -> None:
|
||||
"""Drop the stored access token, it is a user credential."""
|
||||
|
||||
if self.owner_access_token is None:
|
||||
return
|
||||
|
||||
self.owner_access_token = None
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -19,7 +19,6 @@ from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.push_recording import push_recording
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,15 +45,7 @@ class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording, then push the video to the owner's Drive."""
|
||||
|
||||
try:
|
||||
return self._notify_by_mode(recording)
|
||||
finally:
|
||||
self._push_recording_to_drive(recording)
|
||||
|
||||
def _notify_by_mode(self, recording):
|
||||
"""Route a recording to the services its mode calls for."""
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
return self._notify_summary_service(recording)
|
||||
@@ -231,31 +222,6 @@ class NotificationService:
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _push_recording_to_drive(recording: models.Recording):
|
||||
"""Hand the recording over to the task pushing it to the owner's Drive."""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
if recording.mode != models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
recording.clear_owner_access_token()
|
||||
return
|
||||
|
||||
if not recording.owner_access_token:
|
||||
logger.warning(
|
||||
"No access token stored for recording %s, skipping the Drive push",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
push_recording.delay(str(recording.id))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"Could not schedule the Drive push of recording %s", recording.id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
@@ -107,4 +107,4 @@ class RecordingEventsService:
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save(update_fields=["status", "updated_at"])
|
||||
recording.save()
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Client for La Suite Drive's external API (OIDC resource server).
|
||||
|
||||
Drive exposes `/external_api/v1.0/*` to applications holding an user's OIDC
|
||||
access token.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts, in seconds. The upload one covers a
|
||||
# whole recording being relayed to Drive's object storage.
|
||||
API_TIMEOUT = (10, 30)
|
||||
UPLOAD_TIMEOUT = (10, 1800)
|
||||
|
||||
|
||||
class DriveError(Exception):
|
||||
"""Raised when Drive's external API cannot fulfill a request."""
|
||||
|
||||
|
||||
class SizedStream:
|
||||
"""Read-only byte stream of a known size, suitable as a `requests` body.
|
||||
|
||||
`requests` falls back to a chunked transfer encoding when it cannot guess the
|
||||
body size upfront, which presigned S3 uploads reject. Advertising the size
|
||||
through `__len__` makes it send a plain `Content-Length` instead, while the
|
||||
underlying stream is still consumed chunk by chunk.
|
||||
"""
|
||||
|
||||
def __init__(self, stream, length: int):
|
||||
"""Wrap `stream`, whose full content is `length` bytes long."""
|
||||
self._stream = stream
|
||||
self._length = length
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the total size of the stream, in bytes."""
|
||||
return self._length
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the stream, required for `requests` to stream the body."""
|
||||
return iter(self._stream)
|
||||
|
||||
def read(self, amt=None) -> bytes:
|
||||
"""Read up to `amt` bytes from the stream."""
|
||||
return self._stream.read(amt)
|
||||
|
||||
|
||||
class DriveClient:
|
||||
"""Access Drive's external API on behalf of a user.
|
||||
|
||||
The client is bound to a single user access token: every call is performed
|
||||
as that user, and Drive applies its own permissions accordingly.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, *, base_url: str | None = None):
|
||||
"""Prepare a session authenticated with the user's OIDC access token."""
|
||||
|
||||
self._base_url = (base_url or settings.DRIVE_API_BASE_URL or "").rstrip("/")
|
||||
|
||||
if not self._base_url:
|
||||
raise DriveError(
|
||||
"Drive API is not configured, set DRIVE_API_BASE_URL to enable it."
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
raise DriveError("An access token is required to call Drive.")
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow use as a context manager, closing the session on exit."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close the underlying HTTP session."""
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Release the underlying HTTP session."""
|
||||
self._session.close()
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
"""Perform an authenticated call to the external API and return its body."""
|
||||
|
||||
url = f"{self._base_url}{path}"
|
||||
kwargs.setdefault("timeout", API_TIMEOUT)
|
||||
|
||||
try:
|
||||
response = self._session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError(f"Drive call failed: {method} {url}") from exc
|
||||
|
||||
if not response.content:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise DriveError(f"Drive returned a non-JSON body for {url}") from exc
|
||||
|
||||
def create_file(self, *, filename: str) -> dict:
|
||||
"""Create a file item at the root of the user's Drive and return it.
|
||||
|
||||
The returned item carries a `policy`: the presigned URL the content has
|
||||
to be uploaded to.
|
||||
"""
|
||||
|
||||
item = self._request(
|
||||
"POST",
|
||||
"/items/",
|
||||
json={"type": "file", "filename": filename},
|
||||
)
|
||||
|
||||
if not item or not item.get("policy"):
|
||||
raise DriveError(
|
||||
f"Drive did not return an upload policy for file '{filename}'."
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _resolve_upload_target(policy_url: str) -> tuple[str, str | None]:
|
||||
"""Return the address to connect to, and the `Host` header to send.
|
||||
|
||||
Drive signs its upload URLs with the object storage domain meant for
|
||||
browsers, which may not resolve from dev split docker compose setup.
|
||||
The signature covers the `Host` header, so we swap the address we connect
|
||||
to but keep announcing the original host.
|
||||
"""
|
||||
|
||||
override = settings.DRIVE_UPLOAD_STORAGE_NETLOC
|
||||
|
||||
if not override:
|
||||
return policy_url, None
|
||||
|
||||
parsed = urlparse(policy_url)
|
||||
return urlunparse(parsed._replace(netloc=override)), parsed.netloc
|
||||
|
||||
def upload_content(self, *, policy_url: str, stream, content_length, content_type):
|
||||
"""Push `stream` to the presigned URL, without buffering it as a whole."""
|
||||
|
||||
url, host_header = self._resolve_upload_target(policy_url)
|
||||
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(content_length),
|
||||
"x-amz-acl": "private",
|
||||
}
|
||||
|
||||
if host_header:
|
||||
headers["Host"] = host_header
|
||||
|
||||
try:
|
||||
# A bare `requests.put`, not the authenticated session: the presigned
|
||||
# URL carries its own credentials
|
||||
response = requests.put(
|
||||
url,
|
||||
data=SizedStream(stream, content_length),
|
||||
headers=headers,
|
||||
timeout=UPLOAD_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError("Upload to Drive's object storage failed.") from exc
|
||||
|
||||
def complete_upload(self, item_id: str) -> None:
|
||||
"""Inform Drive that the upload is over, making the file available."""
|
||||
|
||||
self._request("POST", f"/items/{item_id}/upload-ended/", json={})
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Asynchronous tasks of the core application.
|
||||
|
||||
Importing the task modules here is what makes Celery's `autodiscover_tasks`
|
||||
register them: it only imports the `core.tasks` package itself, never its
|
||||
submodules.
|
||||
"""
|
||||
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
__all__ = ["process_file_deletion", "push_recording"]
|
||||
@@ -1,12 +1,4 @@
|
||||
"""
|
||||
Celery task decorator that degrades to a synchronous call when Celery is off.
|
||||
"""
|
||||
|
||||
# The Celery app is imported lazily so that importing this module does not pull
|
||||
# in Celery when CELERY_ENABLED is false.
|
||||
|
||||
# ruff: noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Task pushing a finished recording to its owner's Drive."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.services.drive import API_TIMEOUT, DriveClient, DriveError
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts for the download, in seconds. The read one has to
|
||||
# accommodate a whole recording being relayed.
|
||||
DOWNLOAD_TIMEOUT = (API_TIMEOUT[0], 1800)
|
||||
|
||||
|
||||
def _build_filename(recording: models.Recording) -> str:
|
||||
"""Return a filename for the Drive item."""
|
||||
|
||||
return (
|
||||
f"{recording.room.slug}-"
|
||||
f"{recording.created_at:%Y-%m-%d-%H-%M}."
|
||||
f"{recording.extension}"
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def push_recording(recording_id: str) -> bool:
|
||||
"""Push a recording to the Drive of the user who started it.
|
||||
|
||||
The recording is streamed from object storage to Drive's presigned
|
||||
URL. It is NOT fully downloaded to the worker's disk or memory.
|
||||
|
||||
The access token stored when the recording started is consumed here and
|
||||
dropped afterwards whatever the outcome.
|
||||
|
||||
Mostly taken from: https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md
|
||||
"""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(pk=recording_id)
|
||||
except models.Recording.DoesNotExist:
|
||||
logger.error(
|
||||
"Recording %s does not exist, cannot push it to Drive", recording_id
|
||||
)
|
||||
return False
|
||||
|
||||
access_token = recording.get_owner_access_token()
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"No access token stored for recording %s, cannot push it to Drive. "
|
||||
"Was OIDC_STORE_ACCESS_TOKEN enabled when the recording started?",
|
||||
recording_id,
|
||||
)
|
||||
return False
|
||||
|
||||
download_url = utils.generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
)
|
||||
filename = _build_filename(recording)
|
||||
|
||||
try:
|
||||
with DriveClient(access_token) as drive:
|
||||
item = drive.create_file(filename=filename)
|
||||
|
||||
# The bytes are relayed chunk by chunk: the recording is never held
|
||||
# in memory as a whole.
|
||||
with requests.get(
|
||||
download_url, stream=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as download:
|
||||
download.raise_for_status()
|
||||
|
||||
content_length = download.headers.get("Content-Length")
|
||||
if content_length is None:
|
||||
raise DriveError(
|
||||
"Object storage did not return the recording size, "
|
||||
"cannot stream it to Drive."
|
||||
)
|
||||
|
||||
drive.upload_content(
|
||||
policy_url=item["policy"],
|
||||
stream=download.raw,
|
||||
content_length=int(content_length),
|
||||
content_type=download.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
)
|
||||
|
||||
drive.complete_upload(item["id"])
|
||||
|
||||
except (DriveError, requests.RequestException):
|
||||
logger.exception("Failed to push recording %s to Drive", recording_id)
|
||||
return False
|
||||
|
||||
finally:
|
||||
recording.clear_owner_access_token()
|
||||
|
||||
logger.info(
|
||||
"Recording %s pushed to Drive as '%s' (item %s)",
|
||||
recording_id,
|
||||
filename,
|
||||
item["id"],
|
||||
)
|
||||
return True
|
||||
@@ -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 in ["meet-minio:9000", "minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -3,14 +3,13 @@ Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room, RoomAccessLevel
|
||||
from ...models import Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -110,205 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
||||
"""
|
||||
The user's default room access level should be applied to the new room
|
||||
when the request does not provide one.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
|
||||
"""
|
||||
An access level explicitly provided in the request should take precedence
|
||||
over the user's default room access level.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
"access_level": RoomAccessLevel.TRUSTED,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_no_user_default_access_level():
|
||||
"""
|
||||
When the user has no default room access level, the instance default
|
||||
should be applied to the new room.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_user_default_configuration():
|
||||
"""
|
||||
The user's default room configuration should be applied to the new room
|
||||
when the request does not provide one.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"everyone_can_mute": False}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
|
||||
"""
|
||||
A configuration explicitly provided in the request should take precedence
|
||||
over the user's default room configuration.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
|
||||
"""
|
||||
An empty configuration in the request should not be considered an explicit
|
||||
value: the user's default room configuration should still be applied.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
"configuration": {},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"everyone_can_mute": True}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_empty_user_default_configuration():
|
||||
"""
|
||||
When the user's default room configuration is empty, the new room should
|
||||
keep its default empty configuration.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
|
||||
"""
|
||||
When the user's default room configuration is empty, the request should take precedence.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{"name": "my room", "configuration": {"everyone_can_mute": True}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"everyone_can_mute": True}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
||||
"""
|
||||
A blank default room access level (stored as an empty string) should be
|
||||
treated as unset: the instance default should be applied to the new room
|
||||
instead of persisting an invalid empty access level.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level="")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
@@ -453,8 +453,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
@@ -468,8 +466,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
|
||||
@@ -119,8 +119,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
Test the default room preferences exposed on the users API.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_me_includes_default_room_preferences():
|
||||
"""The "me" endpoint should expose the user's default room preferences."""
|
||||
user = factories.UserFactory(
|
||||
default_room_access_level="restricted",
|
||||
default_room_configuration={"everyone_can_mute": False},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["default_room_access_level"] == "restricted"
|
||||
assert content["default_room_configuration"] == {"everyone_can_mute": False}
|
||||
|
||||
|
||||
def test_api_users_update_default_room_preferences():
|
||||
"""Users should be able to update their own default room preferences."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{
|
||||
"default_room_access_level": "trusted",
|
||||
"default_room_configuration": {
|
||||
"can_publish_sources": ["microphone", "camera"],
|
||||
"everyone_can_mute": False,
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert user.default_room_access_level == "trusted"
|
||||
assert user.default_room_configuration == {
|
||||
"can_publish_sources": ["microphone", "camera"],
|
||||
"everyone_can_mute": False,
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_update_default_room_access_level_invalid():
|
||||
"""An invalid access level should be rejected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{"default_room_access_level": "invalid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
user.refresh_from_db()
|
||||
assert user.default_room_access_level is None
|
||||
|
||||
|
||||
def test_api_users_update_default_room_configuration_invalid():
|
||||
"""An invalid room configuration should be rejected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{"default_room_configuration": {"unknown_field": True}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
user.refresh_from_db()
|
||||
assert user.default_room_configuration == {}
|
||||
|
||||
|
||||
def test_api_users_update_other_user_default_room_preferences_forbidden():
|
||||
"""Users should not be able to update someone else's preferences."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
{"default_room_access_level": "restricted"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
other_user.refresh_from_db()
|
||||
assert other_user.default_room_access_level is None
|
||||
@@ -17,7 +17,6 @@ from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
@@ -26,7 +25,6 @@ import botocore
|
||||
import magic
|
||||
import phonenumbers
|
||||
from asgiref.sync import async_to_sync
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
ListRoomsRequest,
|
||||
@@ -419,42 +417,6 @@ def generate_upload_policy(file):
|
||||
return policy
|
||||
|
||||
|
||||
class SecretDecryptionError(Exception):
|
||||
"""Raised when a stored secret cannot be decrypted."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_cipher_suite():
|
||||
"""Return the Fernet key used to encrypt secrets at rest.
|
||||
|
||||
The same key as django-lasuite's OIDC token storage.
|
||||
"""
|
||||
|
||||
key = settings.OIDC_STORE_REFRESH_TOKEN_KEY
|
||||
|
||||
if not key:
|
||||
raise ImproperlyConfigured("OIDC_STORE_REFRESH_TOKEN_KEY setting is required.")
|
||||
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
"""Encrypt a secret meant to be stored at rest."""
|
||||
|
||||
return get_cipher_suite().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
"""Decrypt a secret stored by `encrypt_secret`."""
|
||||
|
||||
try:
|
||||
return get_cipher_suite().decrypt(value.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise SecretDecryptionError(
|
||||
"The stored secret could not be decrypted."
|
||||
) from exc
|
||||
|
||||
|
||||
def generate_download_s3_url(
|
||||
key: str, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
|
||||
@@ -573,20 +573,6 @@ class Base(Configuration):
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Required to call other La Suite applications on behalf of the user, e.g.
|
||||
# to push a recording to their Drive.
|
||||
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Fernet key used to encrypt OIDC tokens at rest, both the refresh token
|
||||
# django-lasuite stores in the session and the access token stored on a
|
||||
# recording. Generate one with `Fernet.generate_key()`.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = SecretFileValue(
|
||||
None, environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", environ_prefix=None
|
||||
)
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
@@ -739,29 +725,6 @@ class Base(Configuration):
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Push recordings to Drive
|
||||
# Once a recording is over, it is pushed to the user's
|
||||
# started it, using their OIDC access token. It requires OIDC_STORE_ACCESS_TOKEN,
|
||||
# and Drive to be configured as an OIDC resource server accepting Meet's audience.
|
||||
RECORDING_PUSH_TO_DRIVE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_PUSH_TO_DRIVE_ENABLED", environ_prefix=None
|
||||
)
|
||||
# Base URL of Drive's external API, e.g. https://fichiers.numerique.gouv.fr/external_api/v1.0
|
||||
DRIVE_API_BASE_URL = values.Value(
|
||||
None, environ_name="DRIVE_API_BASE_URL", environ_prefix=None
|
||||
)
|
||||
# Lifetime of the signed URL the worker downloads the recording from.
|
||||
RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS = values.PositiveIntegerValue(
|
||||
60 * 60,
|
||||
environ_name="RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Development only: host:port to reach Drive's object storage at, when the
|
||||
# domain Drive signs its upload URLs with is only resolvable from a browser.
|
||||
DRIVE_UPLOAD_STORAGE_NETLOC = values.Value(
|
||||
None, environ_name="DRIVE_UPLOAD_STORAGE_NETLOC", 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
|
||||
|
||||
@@ -2,7 +2,6 @@ import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import type { Track } from 'livekit-client'
|
||||
type Source = Track.Source
|
||||
|
||||
@@ -50,9 +49,6 @@ export interface ApiConfig {
|
||||
international_phone_number?: string
|
||||
default_country?: string
|
||||
}
|
||||
resource?: {
|
||||
default_access_level?: ApiAccessLevel
|
||||
}
|
||||
manifest_link?: string
|
||||
livekit: {
|
||||
url: string
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
import type {
|
||||
ApiAccessLevel,
|
||||
RoomConfiguration,
|
||||
} from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
@@ -11,6 +7,4 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
default_room_access_level?: ApiAccessLevel | null
|
||||
default_room_configuration?: RoomConfiguration | null
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
|
||||
export type PatchUserParams = {
|
||||
userId: string
|
||||
user: Partial<
|
||||
Pick<
|
||||
ApiUser,
|
||||
| 'timezone'
|
||||
| 'language'
|
||||
| 'default_room_access_level'
|
||||
| 'default_room_configuration'
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
export const patchUser = ({ userId, user }: PatchUserParams) => {
|
||||
return fetchApi<ApiUser>(`/users/${userId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(user),
|
||||
})
|
||||
}
|
||||
|
||||
export const patchUserMutationKey = ['patchUser']
|
||||
|
||||
export function usePatchUser(
|
||||
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
|
||||
) {
|
||||
return useMutation<ApiUser, ApiError, PatchUserParams>({
|
||||
mutationKey: patchUserMutationKey,
|
||||
mutationFn: patchUser,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
||||
|
||||
export type MediaStateChangedDetail = {
|
||||
microphoneEnabled: boolean
|
||||
cameraEnabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the local participant's media state in the DOM so external tools
|
||||
* (e.g. bots automating the frontend) can reliably read the microphone and
|
||||
* camera state, and watch for changes with a MutationObserver:
|
||||
*
|
||||
* const el = document.getElementById('media-state')
|
||||
* new MutationObserver(...).observe(el, { attributes: true })
|
||||
*/
|
||||
export const MediaStateObserver = () => {
|
||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<MediaStateChangedDetail>(MEDIA_STATE_CHANGED_EVENT, {
|
||||
detail: {
|
||||
microphoneEnabled: isMicrophoneEnabled,
|
||||
cameraEnabled: isCameraEnabled,
|
||||
},
|
||||
})
|
||||
)
|
||||
}, [isMicrophoneEnabled, isCameraEnabled])
|
||||
|
||||
return (
|
||||
<div
|
||||
id={MEDIA_STATE_ELEMENT_ID}
|
||||
style={{ display: 'none' }}
|
||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { SidePanel } from '../components/SidePanel'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
@@ -64,7 +63,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<>
|
||||
<RoomMetadataSynchronizer />
|
||||
<ConnectionObserver />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<VideoResolutionSubscription />
|
||||
<div
|
||||
|
||||
@@ -2,12 +2,9 @@ export class CallbackIdHandler {
|
||||
private readonly storageKey = 'popup_callback_id'
|
||||
|
||||
private generateId(): string {
|
||||
// The id is the only thing guarding /rooms/creation-callback/, which is
|
||||
// unauthenticated, so it comes from the CSPRNG rather than Math.random.
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +1,22 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useRef } from 'react'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiSettings3Line, RiDoorOpenLine } from '@remixicon/react'
|
||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||
import { Tab, TabList, TabPanel, Tabs } from '@/primitives/Tabs'
|
||||
import { text } from '@/primitives/Text.tsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { logout } from '@/features/auth/utils/logout'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { RoomsTab } from './tabs/RoomsTab'
|
||||
|
||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||
|
||||
enum SettingsDialogTabKey {
|
||||
GENERAL = 'general',
|
||||
ROOMS = 'rooms',
|
||||
}
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
width: '50rem', // fixme size copied from meet settings modal
|
||||
marginY: '-1rem', // fixme hacky solution to cancel modal padding
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
height: 'calc(100vh - 2rem)',
|
||||
})
|
||||
|
||||
const tabListContainerStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid lightGray', // fixme poor color management
|
||||
paddingY: '1rem',
|
||||
paddingLeft: '0.2rem',
|
||||
paddingRight: '1.5rem',
|
||||
})
|
||||
|
||||
const tabPanelContainerStyle = css({
|
||||
display: 'flex',
|
||||
flexGrow: '1',
|
||||
marginTop: '3.5rem',
|
||||
minWidth: 0,
|
||||
})
|
||||
|
||||
const tabPanelStyle = css({
|
||||
flexGrow: '1',
|
||||
minWidth: 0,
|
||||
overflowY: 'auto',
|
||||
paddingRight: '1.5rem',
|
||||
paddingBottom: '1rem',
|
||||
})
|
||||
|
||||
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
const { t, i18n } = useTranslation('settings')
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
|
||||
const dialogEl = useRef<HTMLDivElement>(null)
|
||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||
|
||||
const userDisplay =
|
||||
user?.full_name && user?.email
|
||||
? `${user.full_name} (${user.email})`
|
||||
: user?.email
|
||||
|
||||
const generalContent = (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: '360px',
|
||||
})}
|
||||
>
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')} {...props}>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
@@ -104,56 +47,6 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
i18n.changeLanguage(lang as string)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Without tabs there is no rail to host the heading, so keep the plain dialog.
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')} {...props} role="dialog" type="flex">
|
||||
{generalContent}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
className={tabsStyle}
|
||||
defaultSelectedKey={SettingsDialogTabKey.GENERAL}
|
||||
>
|
||||
<div
|
||||
className={tabListContainerStyle}
|
||||
style={{
|
||||
flex: isWideScreen ? '0 0 16rem' : undefined,
|
||||
paddingTop: !isWideScreen ? '64px' : undefined,
|
||||
paddingRight: !isWideScreen ? '1rem' : undefined,
|
||||
}}
|
||||
>
|
||||
{isWideScreen && (
|
||||
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
|
||||
{t('dialog.heading')}
|
||||
</Heading>
|
||||
)}
|
||||
<TabList border={false}>
|
||||
<Tab icon highlight id={SettingsDialogTabKey.GENERAL}>
|
||||
<RiSettings3Line />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogTabKey.ROOMS}>
|
||||
<RiDoorOpenLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
<TabPanel id={SettingsDialogTabKey.GENERAL} className={tabPanelStyle}>
|
||||
{generalContent}
|
||||
</TabPanel>
|
||||
<RoomsTab id={SettingsDialogTabKey.ROOMS} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import {
|
||||
usePatchUser,
|
||||
patchUserMutationKey,
|
||||
} from '@/features/auth/api/patchUser'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { ApiAccessLevel, RoomConfiguration } from '@/features/rooms/api/ApiRoom'
|
||||
import { useMemo } from 'react'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||
import { Field, H, Text } from '@/primitives'
|
||||
import { TabPanel } from '@/primitives/Tabs'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
|
||||
type RoomsTabProps = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const RoomsTab = ({ id }: RoomsTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'roomDefaults' })
|
||||
const { t: tAdmin } = useTranslation('rooms', {
|
||||
keyPrefix: 'admin',
|
||||
useSuspense: false,
|
||||
})
|
||||
|
||||
const { user } = useUser()
|
||||
const { data: configData } = useConfig()
|
||||
|
||||
// Optimistic updates: patch the cache immediately so the UI updates
|
||||
// instantly and concurrent saves always build on the latest local state.
|
||||
// Since each PATCH replaces the full JSON config, this avoids overwriting
|
||||
// earlier changes with a stale snapshot.
|
||||
//
|
||||
// No per-request rollback: later requests already include earlier changes.
|
||||
// Once the last in-flight save completes, re-fetch the server state once to
|
||||
// restore the UI if all saves failed.
|
||||
const { mutate: patchUser } = usePatchUser({
|
||||
onMutate: async ({ user: partialUser }) => {
|
||||
await queryClient.cancelQueries({ queryKey: [keys.user] })
|
||||
queryClient.setQueryData<ApiUser | false>([keys.user], (previous) =>
|
||||
previous ? { ...previous, ...partialUser } : previous
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
if (queryClient.isMutating({ mutationKey: patchUserMutationKey }) === 1) {
|
||||
queryClient.invalidateQueries({ queryKey: [keys.user] })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const configuration: RoomConfiguration = useMemo(
|
||||
() => user?.default_room_configuration ?? {},
|
||||
[user?.default_room_configuration]
|
||||
)
|
||||
|
||||
const currentSources: Source[] = useMemo(() => {
|
||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
||||
if (!Array.isArray(configuration?.can_publish_sources)) {
|
||||
return defaultSources
|
||||
}
|
||||
return configuration.can_publish_sources
|
||||
}, [configData, configuration])
|
||||
|
||||
const accessLevel =
|
||||
user?.default_room_access_level ??
|
||||
configData?.resource?.default_access_level ??
|
||||
ApiAccessLevel.PUBLIC
|
||||
|
||||
// Every change saves immediately; the optimistic onMutate above keeps the
|
||||
// cached user (and therefore `configuration`) in sync right away.
|
||||
const saveConfiguration = (newConfiguration: RoomConfiguration) => {
|
||||
if (!user) return
|
||||
patchUser({
|
||||
userId: user.id,
|
||||
user: { default_room_configuration: newConfiguration },
|
||||
})
|
||||
}
|
||||
|
||||
const updateSource = (sources: Source[], enabled: boolean) =>
|
||||
saveConfiguration({
|
||||
...configuration,
|
||||
can_publish_sources: updatePublishSources(
|
||||
currentSources,
|
||||
sources,
|
||||
enabled
|
||||
),
|
||||
})
|
||||
|
||||
const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources)
|
||||
const isCameraEnabled = isSubsetOf([Source.Camera], currentSources)
|
||||
const isScreenShareEnabled = isSubsetOf(
|
||||
[Source.ScreenShare, Source.ScreenShareAudio],
|
||||
currentSources
|
||||
)
|
||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
||||
|
||||
const saveAccessLevel = (newAccessLevel: ApiAccessLevel) => {
|
||||
if (!user) return
|
||||
patchUser({
|
||||
userId: user.id,
|
||||
user: { default_room_access_level: newAccessLevel },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('heading')}</H>
|
||||
<Text variant="note" margin={'md'}>
|
||||
{t('description')}
|
||||
</Text>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
background: 'greyscale.250',
|
||||
})}
|
||||
/>
|
||||
<H
|
||||
lvl={3}
|
||||
variant={'h2'}
|
||||
className={css({
|
||||
fontWeight: 500,
|
||||
})}
|
||||
margin="sm"
|
||||
>
|
||||
{tAdmin('moderation.title')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
})}
|
||||
margin={'md'}
|
||||
>
|
||||
{tAdmin('moderation.description')}
|
||||
</Text>
|
||||
<Field
|
||||
type="switch"
|
||||
label={tAdmin('moderation.microphone.label')}
|
||||
isSelected={isMicrophoneEnabled}
|
||||
onChange={(enabled) => updateSource([Source.Microphone], enabled)}
|
||||
/>
|
||||
<Field
|
||||
type="switch"
|
||||
label={tAdmin('moderation.camera.label')}
|
||||
isSelected={isCameraEnabled}
|
||||
onChange={(enabled) => updateSource([Source.Camera], enabled)}
|
||||
/>
|
||||
<Field
|
||||
type="switch"
|
||||
label={tAdmin('moderation.screenshare.label')}
|
||||
isSelected={isScreenShareEnabled}
|
||||
onChange={(enabled) =>
|
||||
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
type="switch"
|
||||
label={tAdmin('moderation.mute.label')}
|
||||
isSelected={isMutingEnabled}
|
||||
onChange={(enabled) =>
|
||||
saveConfiguration({ ...configuration, everyone_can_mute: enabled })
|
||||
}
|
||||
/>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
marginY: '1rem',
|
||||
background: 'greyscale.250',
|
||||
})}
|
||||
/>
|
||||
<H
|
||||
lvl={3}
|
||||
variant={'h2'}
|
||||
className={css({
|
||||
fontWeight: 500,
|
||||
})}
|
||||
margin="sm"
|
||||
>
|
||||
{tAdmin('access.title')}
|
||||
</H>
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label={tAdmin('access.type')}
|
||||
value={accessLevel}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}),
|
||||
}}
|
||||
onChange={(value) => saveAccessLevel(value as ApiAccessLevel)}
|
||||
items={[
|
||||
{
|
||||
value: ApiAccessLevel.PUBLIC,
|
||||
label: tAdmin('access.levels.public.label'),
|
||||
description: tAdmin('access.levels.public.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: tAdmin('access.levels.trusted.label'),
|
||||
description: tAdmin('access.levels.trusted.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: tAdmin('access.levels.restricted.label'),
|
||||
description: tAdmin('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Benachrichtigungen",
|
||||
"accessibility": "Barrierefreiheit",
|
||||
"transcription": "Transkription",
|
||||
"shortcuts": "Tastenkürzel",
|
||||
"rooms": "Räume"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Standardeinstellungen für Räume",
|
||||
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
|
||||
"shortcuts": "Tastenkürzel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Shortcuts",
|
||||
"rooms": "Rooms"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Default room settings",
|
||||
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
|
||||
"shortcuts": "Shortcuts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Raccourcis",
|
||||
"rooms": "Réunions"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Paramètres par défaut des réunions",
|
||||
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
|
||||
"shortcuts": "Raccourcis"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Meldingen",
|
||||
"accessibility": "Toegankelijkheid",
|
||||
"transcription": "Transcriptie",
|
||||
"shortcuts": "Sneltoetsen",
|
||||
"rooms": "Vergaderingen"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Standaardinstellingen voor vergaderingen",
|
||||
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
|
||||
"shortcuts": "Sneltoetsen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,13 @@ def test_media_info_ignores_empty_stream_entry(monkeypatch: pytest.MonkeyPatch)
|
||||
|
||||
def test_extract_audio_from_video():
|
||||
"""Test that extract_audio_from_video can extract audio from a video file."""
|
||||
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||
path = None
|
||||
# A bit of cleanup logic since this is not a generator
|
||||
try:
|
||||
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||
assert path.name.endswith(".m4a")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to extract audio from video: {e}")
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
if path and path.exists():
|
||||
path.unlink()
|
||||
|
||||
Reference in New Issue
Block a user