Compare commits

..

3 Commits

Author SHA1 Message Date
leo 5b92ae8f73 wip 2026-07-29 19:02:35 +02:00
leo ac2b5bd4f3 wip 2026-07-29 18:57:48 +02:00
leo 7c92f6054b wip 2026-07-29 18:57:48 +02:00
40 changed files with 1253 additions and 993 deletions
+4 -1
View File
@@ -10,11 +10,14 @@ and this project adheres to
### Added
- ✨(backend) push recordings to the owner's Drive (POC)
## Fixed
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
- ✨(frontend) add connection test feature
### Changed
+6
View File
@@ -84,6 +84,7 @@ bootstrap: \
data/media \
data/static \
create-env-files \
create-docker-network \
build \
migrate \
demo \
@@ -117,11 +118,16 @@ 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
@echo "Wait for postgresql to be up..."
+25 -8
View File
@@ -14,6 +14,9 @@ services:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
networks:
- default
- lasuite
minio:
user: ${DOCKER_USER:-1000}
@@ -33,6 +36,13 @@ services:
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
networks:
default:
# The backend containers also sit on lasuite-network, where Drive's own
# minio answers to "minio" as well. They address this one by an alias no
# other stack uses, so the two can never race in DNS.
aliases:
- meet-minio
createbuckets:
image: minio/mc
@@ -93,6 +103,7 @@ services:
networks:
- resource-server
- default
- lasuite
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -109,6 +120,9 @@ services:
- /app/.venv
depends_on:
- app-dev
networks:
- default
- lasuite
app:
build:
@@ -196,32 +210,32 @@ services:
- env.d/development/kc_postgresql
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
image: quay.io/keycloak/keycloak:26.3.2
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --proxy-headers=xforwarded
- --hostname=http://localhost:8083
- --hostname-strict=false
- --hostname-strict-https=false
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
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
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
depends_on:
- kc_postgresql
networks:
- default
- lasuite
livekit:
image: livekit/livekit-server
@@ -338,3 +352,6 @@ services:
networks:
default:
resource-server:
lasuite:
name: lasuite-network
external: true
+237 -32
View File
@@ -56,7 +56,9 @@
"value": "meet"
}
],
"realmRoles": ["user"]
"realmRoles": [
"user"
]
},
{
"username": "user-e2e-chromium",
@@ -70,7 +72,9 @@
"value": "password-e2e-chromium"
}
],
"realmRoles": ["user"]
"realmRoles": [
"user"
]
},
{
"username": "user-e2e-webkit",
@@ -84,7 +88,9 @@
"value": "password-e2e-webkit"
}
],
"realmRoles": ["user"]
"realmRoles": [
"user"
]
},
{
"username": "user-e2e-firefox",
@@ -98,7 +104,9 @@
"value": "password-e2e-firefox"
}
],
"realmRoles": ["user"]
"realmRoles": [
"user"
]
}
],
"roles": {
@@ -118,9 +126,15 @@
"description": "${role_default-roles}",
"composite": "true",
"composites": {
"realm": ["offline_access", "uma_authorization"],
"realm": [
"offline_access",
"uma_authorization"
],
"client": {
"account": ["view-profile", "manage-account"]
"account": [
"view-profile",
"manage-account"
]
}
},
"clientRole": "false",
@@ -269,7 +283,9 @@
"composite": "true",
"composites": {
"client": {
"realm-management": ["query-clients"]
"realm-management": [
"query-clients"
]
}
},
"clientRole": "true",
@@ -292,7 +308,10 @@
"composite": "true",
"composites": {
"client": {
"realm-management": ["query-users", "query-groups"]
"realm-management": [
"query-users",
"query-groups"
]
}
},
"clientRole": "true",
@@ -368,7 +387,9 @@
"composite": "true",
"composites": {
"client": {
"account": ["view-consent"]
"account": [
"view-consent"
]
}
},
"clientRole": "true",
@@ -400,7 +421,9 @@
"composite": "true",
"composites": {
"client": {
"account": ["manage-account-links"]
"account": [
"manage-account-links"
]
}
},
"clientRole": "true",
@@ -455,7 +478,9 @@
"clientRole": "false",
"containerId": "ccf4fd40-4286-474d-854a-4714282a8bec"
},
"requiredCredentials": ["password"],
"requiredCredentials": [
"password"
],
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA1",
"otpPolicyInitialCounter": 0,
@@ -463,9 +488,14 @@
"otpPolicyLookAheadWindow": 1,
"otpPolicyPeriod": 30,
"otpPolicyCodeReusable": "false",
"otpSupportedApplications": ["totpAppGoogleName", "totpAppFreeOTPName"],
"otpSupportedApplications": [
"totpAppGoogleName",
"totpAppFreeOTPName"
],
"webAuthnPolicyRpEntityName": "keycloak",
"webAuthnPolicySignatureAlgorithms": ["ES256"],
"webAuthnPolicySignatureAlgorithms": [
"ES256"
],
"webAuthnPolicyRpId": "",
"webAuthnPolicyAttestationConveyancePreference": "not specified",
"webAuthnPolicyAuthenticatorAttachment": "not specified",
@@ -475,7 +505,9 @@
"webAuthnPolicyAvoidSameAuthenticatorRegister": "false",
"webAuthnPolicyAcceptableAaguids": [],
"webAuthnPolicyPasswordlessRpEntityName": "keycloak",
"webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"],
"webAuthnPolicyPasswordlessSignatureAlgorithms": [
"ES256"
],
"webAuthnPolicyPasswordlessRpId": "",
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified",
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
@@ -487,14 +519,19 @@
"scopeMappings": [
{
"clientScope": "offline_access",
"roles": ["offline_access"]
"roles": [
"offline_access"
]
}
],
"clientScopeMappings": {
"account": [
{
"client": "account-console",
"roles": ["manage-account", "view-groups"]
"roles": [
"manage-account",
"view-groups"
]
}
]
},
@@ -509,7 +546,9 @@
"enabled": "true",
"alwaysDisplayInConsole": "false",
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/realms/meet/account/*"],
"redirectUris": [
"/realms/meet/account/*"
],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": "false",
@@ -551,7 +590,9 @@
"enabled": "true",
"alwaysDisplayInConsole": "false",
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/realms/meet/account/*"],
"redirectUris": [
"/realms/meet/account/*"
],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": "false",
@@ -796,8 +837,12 @@
"enabled": "true",
"alwaysDisplayInConsole": "false",
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/admin/meet/console/*"],
"webOrigins": ["+"],
"redirectUris": [
"/admin/meet/console/*"
],
"webOrigins": [
"+"
],
"notBefore": 0,
"bearerOnly": "false",
"consentRequired": "false",
@@ -845,6 +890,142 @@
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "drive",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"http://localhost:3100/*",
"http://localhost:8171/*",
"http://localhost:8085/*"
],
"webOrigins": [
"http://localhost:3100",
"http://localhost:8171",
"http://localhost:8085"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "deploycenter",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"http://localhost:3100/*",
"http://localhost:8171/*",
"http://localhost:8085/*"
],
"webOrigins": [
"http://localhost:3100",
"http://localhost:8171",
"http://localhost:8085"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"clientScopes": [
@@ -1382,7 +1563,9 @@
},
"smtpServer": {},
"eventsEnabled": "false",
"eventsListeners": ["jboss-logging"],
"eventsListeners": [
"jboss-logging"
],
"enabledEventTypes": [],
"adminEventsEnabled": "false",
"adminEventsDetailsEnabled": "false",
@@ -1405,7 +1588,9 @@
"subType": "anonymous",
"subComponents": {},
"config": {
"allow-default-scopes": ["true"]
"allow-default-scopes": [
"true"
]
}
},
{
@@ -1415,7 +1600,9 @@
"subType": "anonymous",
"subComponents": {},
"config": {
"max-clients": ["200"]
"max-clients": [
"200"
]
}
},
{
@@ -1425,7 +1612,9 @@
"subType": "authenticated",
"subComponents": {},
"config": {
"allow-default-scopes": ["true"]
"allow-default-scopes": [
"true"
]
}
},
{
@@ -1481,8 +1670,12 @@
"subType": "anonymous",
"subComponents": {},
"config": {
"host-sending-registration-request-must-match": ["true"],
"client-uris-must-match": ["true"]
"host-sending-registration-request-must-match": [
"true"
],
"client-uris-must-match": [
"true"
]
}
}
],
@@ -1501,7 +1694,9 @@
"providerId": "aes-generated",
"subComponents": {},
"config": {
"priority": ["100"]
"priority": [
"100"
]
}
},
{
@@ -1510,8 +1705,12 @@
"providerId": "hmac-generated",
"subComponents": {},
"config": {
"priority": ["100"],
"algorithm": ["HS256"]
"priority": [
"100"
],
"algorithm": [
"HS256"
]
}
},
{
@@ -1520,8 +1719,12 @@
"providerId": "rsa-enc-generated",
"subComponents": {},
"config": {
"priority": ["100"],
"algorithm": ["RSA-OAEP"]
"priority": [
"100"
],
"algorithm": [
"RSA-OAEP"
]
}
},
{
@@ -1530,7 +1733,9 @@
"providerId": "rsa-generated",
"subComponents": {},
"config": {
"priority": ["100"]
"priority": [
"100"
]
}
}
]
+84
View File
@@ -126,6 +126,90 @@ 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 to the main workspace of the user who
started it in [Drive](https://github.com/suitenumerique/drive), on top of staying
in the object storage. The file is streamed from the object storage to Drive: it
is never fully held in the worker's memory nor written to its disk.
Drive is called as an OIDC resource server, following its
[resource server documentation](https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md):
```mermaid
sequenceDiagram
participant User
participant Backend as Django Backend
participant Worker as Celery Worker
participant Storage as Object Storage
participant Drive
User->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
Backend->>Backend: Park the user's OIDC access token (encrypted)
Note over Backend: Recording in progress...
Storage->>Backend: Storage event notification
Backend->>Worker: Schedule push_recording
Worker->>Drive: GET /items/ (as the user)
Drive-->>Worker: Main workspace
Worker->>Drive: POST /items/{workspace}/children/
Drive-->>Worker: Item + presigned upload URL
Worker->>Storage: GET recording (streamed)
Worker->>Drive: PUT presigned URL (relayed chunk by chunk)
Worker->>Drive: POST /items/{item}/upload-ended/
Worker->>Backend: Drop the parked access token
```
### 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 parked 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 ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)) 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://drive.example.com/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 there 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/).
-51
View File
@@ -1,51 +0,0 @@
"""Connection test API endpoint."""
from datetime import timedelta
from uuid import uuid4
from django.conf import settings
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from core.api.throttling import (
ConnectionTestAnonRateThrottle,
ConnectionTestUserRateThrottle,
)
from core.tasks.connection_test import delete_connection_test_room
from core.utils import generate_token
CONNECTION_TEST_USERNAME = "Test connexion"
@api_view(["GET"])
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
def get_connection_test_config(request):
"""Return a short-lived LiveKit token for an ephemeral connection test room."""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
delete_connection_test_room.apply_async(
args=[room],
countdown=settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS,
)
return Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username=CONNECTION_TEST_USERNAME,
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
-12
View File
@@ -73,15 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
scope = "connection_test"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
+28
View File
@@ -364,6 +364,33 @@ class RoomViewSet(
room.id,
)
@staticmethod
def _park_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 is gone, so the token has to be parked now.
POC limitation: we assume the token is still valid by then. The target
design is a token exchange (RFC 8693) performed here, to get a long-lived
token narrowly scoped to that upload.
"""
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
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"],
@@ -399,6 +426,7 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
recording=recording,
)
self._park_drive_credentials(request, recording)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-07-20 00:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.14 on 2026-07-29 00:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0022_alter_recording_status"),
]
operations = [
migrations.AddField(
model_name="recording",
name="owner_access_token",
field=models.TextField(
blank=True,
editable=False,
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.",
null=True,
verbose_name="Owner access token",
),
),
]
+44
View File
@@ -613,6 +613,17 @@ 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"
@@ -715,6 +726,39 @@ class Recording(BaseModel):
return self.expired_at < timezone.now()
def set_owner_access_token(self, access_token: str) -> None:
"""Park 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 parked 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 parked 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,6 +19,7 @@ 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__)
@@ -45,7 +46,17 @@ class NotificationService:
"""Service for processing recordings and notifying external services."""
def notify_external_services(self, recording):
"""Process a recording based on its mode."""
"""Process a recording, then push it to the owner's Drive."""
try:
return self._notify_by_mode(recording)
finally:
# Independent from the mode: the file itself is pushed to the owner's
# Drive, whether it is a screen recording or a transcript's audio.
self._push_recording_to_drive(recording)
def _notify_by_mode(self, recording):
"""Route a recording to the services its mode calls for."""
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
return self._notify_summary_service(recording)
@@ -222,6 +233,31 @@ 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.
Best effort: a failure here must not compromise the rest of the
notification flow, the recording itself is safe in object storage.
"""
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
return
if not recording.owner_access_token:
logger.warning(
"No access token parked 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."""
+217
View File
@@ -0,0 +1,217 @@
"""Client for La Suite Drive's external API (OIDC resource server).
Drive exposes `/external_api/v1.0/*` to applications holding an end user's OIDC
access token. Uploading a file is a four step dance, documented in Drive's
`docs/resource_server.md`:
1. `GET /items/` to locate the user's main workspace,
2. `POST /items/{workspace_id}/children/` to create the file item, which returns
a presigned upload URL (the `policy`),
3. `PUT {policy}` to push the bytes to Drive's object storage,
4. `POST /items/{item_id}/upload-ended/` to let Drive know the upload is over.
Drive never fetches a URL on our behalf, so the bytes have to transit through
whoever holds the user's token, i.e. us.
"""
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 is generous: it covers a
# whole recording being relayed to Drive's object storage.
API_TIMEOUT = (10, 30)
UPLOAD_TIMEOUT = (10, 300)
# Safety net when walking the paginated item list looking for the main workspace.
MAX_WORKSPACE_PAGES = 10
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:
"""Talk to 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 get_main_workspace(self) -> dict:
"""Return the user's main workspace, the default destination for files."""
path = "/items/"
for _page in range(MAX_WORKSPACE_PAGES):
data = self._request("GET", path) or {}
for item in data.get("results") or []:
if item.get("main_workspace"):
return item
next_url = data.get("next")
if not next_url:
break
# `next` is absolute; keep only what follows the API base URL.
path = next_url[len(self._base_url) :]
raise DriveError("No main workspace found for this user.")
def create_file(self, *, parent_id: str, filename: str) -> dict:
"""Create a file item under `parent_id` and return it.
The returned item carries a `policy`: the presigned URL the content has
to be uploaded to.
"""
item = self._request(
"POST",
f"/items/{parent_id}/children/",
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 its
*browser* clients, which does not necessarily resolve from here — that is
the case in the split docker compose development setup. The presigned
signature covers the `Host` header, so we may only swap the address we
connect to and must 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 and the object storage rejects an
# extra `Authorization` header.
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:
"""Tell Drive the upload is over, which makes the file available."""
self._request("POST", f"/items/{item_id}/upload-ended/", json={})
@@ -228,21 +228,9 @@ class LiveKitEventsService:
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
if self._is_connection_test_room(data.room.name):
logger.info(
"Ignoring room_started event for connection test room '%s'.",
data.room.name,
)
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
@@ -268,13 +256,6 @@ class LiveKitEventsService:
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
if self._is_connection_test_room(data.room.name):
logger.info(
"Ignoring room_finished event for connection test room '%s'.",
data.room.name,
)
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
+8 -6
View File
@@ -1,9 +1,11 @@
"""Celery tasks for the core app."""
"""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.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.tasks.push_recording import push_recording
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
)
__all__ = ["process_file_deletion", "push_recording"]
+3
View File
@@ -1,4 +1,7 @@
"""Celery-optional task decorator."""
# ruff: noqa: PLC0415
# pylint: disable=import-outside-toplevel
from django.conf import settings
-51
View File
@@ -1,51 +0,0 @@
"""Tasks related to connection test rooms."""
import logging
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=no-name-in-module
DeleteRoomRequest,
TwirpError,
)
from core.tasks._task import task
from core.utils import create_livekit_client
logger = logging.getLogger(__name__)
@task
def delete_connection_test_room(room_name: str):
"""Force-delete an ephemeral connection-test room.
Used as a hard cap so a participant cannot keep an auto-refreshed
LiveKit session open indefinitely after requesting a test token.
"""
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
if not room_name.startswith(prefix):
logger.error(
"Refusing to delete room '%s': expected prefix '%s'.",
room_name,
prefix,
)
return
async_to_sync(_delete_room)(room_name)
async def _delete_room(room_name: str):
lkapi = create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted connection test room '%s'.", room_name)
except TwirpError as exc:
# Room may already be gone after empty/departure timeout.
logger.info(
"Could not delete connection test room '%s': %s",
room_name,
exc,
)
finally:
await lkapi.aclose()
+112
View File
@@ -0,0 +1,112 @@
"""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 leg, in seconds. The read one has to
# accommodate a whole recording being relayed.
DOWNLOAD_TIMEOUT = (API_TIMEOUT[0], 300)
def _build_filename(recording: models.Recording) -> str:
"""Return a human-readable 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 main workspace of the user who started it.
The recording is streamed straight from object storage to Drive's presigned
URL: it is never fully downloaded to the worker's disk or memory.
The access token parked when the recording started is consumed here, and
dropped afterwards whatever the outcome — it is a user credential, and a
replay would need a fresh one anyway.
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:
workspace = drive.get_main_workspace()
item = drive.create_file(parent_id=workspace["id"], 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 ["minio:9000", "localhost:9000"]
assert policy_parsed.netloc in ["meet-minio:9000", "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)
@@ -0,0 +1,324 @@
"""
Test pushing a recording to the owner's Drive.
"""
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
from django.test import override_settings
import pytest
import responses
from core import factories, models
from core.recording.event.notification import NotificationService
from core.tasks.push_recording import push_recording
pytestmark = pytest.mark.django_db
DRIVE_API = "https://drive.test/external_api/v1.0"
DOWNLOAD_URL = "https://storage.test/recordings/recording.mp4"
# Signed by Drive with the object storage domain meant for its browser clients.
UPLOAD_URL = "http://drive-storage.test:9100/drive-media/item?X-Amz-Signature=deadbeef"
INTERNAL_UPLOAD_URL = "http://drive-minio:9000/drive-media/item"
WORKSPACE_ID = "11111111-1111-4111-8111-111111111111"
ITEM_ID = "22222222-2222-4222-8222-222222222222"
RECORDING_CONTENT = b"fake-recording-bytes"
@pytest.fixture
def recording_with_token():
"""Return a recording carrying a parked access token."""
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
recording.set_owner_access_token("user-access-token")
return recording
@pytest.fixture
def mocked_download_url():
"""Avoid signing a real S3 URL, the object storage is not the point here."""
with mock.patch(
"core.utils.generate_download_s3_url", return_value=DOWNLOAD_URL
) as patched:
yield patched
@pytest.fixture
def upload():
"""Capture what gets PUT to the presigned URL.
The upload sends a stream, but "responses" drains file-like bodies before
handing the request over, so what lands here is already the bytes.
"""
captured = {}
def callback(request):
captured["url"] = request.url
captured["headers"] = request.headers
captured["body"] = request.body
return 200, {}, ""
captured["callback"] = callback
return captured
def register_download(with_content_length=True):
"""Register the object storage response holding the recording bytes."""
headers = (
{"Content-Length": str(len(RECORDING_CONTENT))} if with_content_length else None
)
responses.add(
responses.GET,
DOWNLOAD_URL,
body=RECORDING_CONTENT,
status=200,
headers=headers,
content_type="video/mp4",
)
def register_drive(upload=None, workspaces=None):
"""Register the Drive API calls of a successful upload."""
responses.add(
responses.GET,
f"{DRIVE_API}/items/",
json={
"results": workspaces
if workspaces is not None
else [
{"id": "shared-workspace", "main_workspace": False},
{"id": WORKSPACE_ID, "main_workspace": True},
],
"next": None,
},
status=200,
)
responses.add(
responses.POST,
f"{DRIVE_API}/items/{WORKSPACE_ID}/children/",
json={"id": ITEM_ID, "policy": UPLOAD_URL},
status=201,
)
if upload is not None:
responses.add_callback(responses.PUT, UPLOAD_URL, callback=upload["callback"])
responses.add_callback(
responses.PUT, INTERNAL_UPLOAD_URL, callback=upload["callback"]
)
responses.add(
responses.POST,
f"{DRIVE_API}/items/{ITEM_ID}/upload-ended/",
json={},
status=200,
)
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
@responses.activate
def test_push_recording_uploads_to_the_main_workspace(
recording_with_token, mocked_download_url, upload
):
"""The recording is created in the main workspace, uploaded, then confirmed."""
register_download()
register_drive(upload=upload)
assert push_recording(str(recording_with_token.id)) is True
workspaces_call, create_call, ended_call = (
responses.calls[0].request,
responses.calls[1].request,
responses.calls[4].request,
)
assert workspaces_call.url == f"{DRIVE_API}/items/"
assert workspaces_call.headers["Authorization"] == "Bearer user-access-token"
room = recording_with_token.room
expected_filename = (
f"{room.slug}-{recording_with_token.created_at:%Y-%m-%d-%H-%M}.mp4"
)
assert expected_filename in create_call.body.decode()
assert upload["url"] == UPLOAD_URL
assert upload["body"] == RECORDING_CONTENT
assert upload["headers"]["Content-Length"] == str(len(RECORDING_CONTENT))
assert upload["headers"]["Content-Type"] == "video/mp4"
assert upload["headers"]["x-amz-acl"] == "private"
# The presigned URL carries its own credentials, an extra Authorization
# header would make the object storage reject the upload.
assert "Authorization" not in upload["headers"]
assert ended_call.url == f"{DRIVE_API}/items/{ITEM_ID}/upload-ended/"
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
@responses.activate
def test_push_recording_drops_the_access_token(
recording_with_token, mocked_download_url, upload
):
"""The parked credential does not outlive the push."""
register_download()
register_drive(upload=upload)
push_recording(str(recording_with_token.id))
recording_with_token.refresh_from_db()
assert recording_with_token.owner_access_token is None
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
@responses.activate
def test_push_recording_drops_the_access_token_on_failure(
recording_with_token, mocked_download_url, upload
):
"""A failed push does not leave the credential behind either."""
register_download()
register_drive(
upload=upload, workspaces=[{"id": "shared", "main_workspace": False}]
)
assert push_recording(str(recording_with_token.id)) is False
recording_with_token.refresh_from_db()
assert recording_with_token.owner_access_token is None
@override_settings(DRIVE_API_BASE_URL=DRIVE_API)
@responses.activate
def test_push_recording_without_parked_token():
"""Without a token there is nobody to push on behalf of, so nothing happens."""
recording = factories.RecordingFactory()
assert push_recording(str(recording.id)) is False
assert not responses.calls
@override_settings(DRIVE_API_BASE_URL=DRIVE_API)
@responses.activate
def test_push_recording_unknown_recording():
"""An unknown recording is reported, not raised."""
assert push_recording("33333333-3333-4333-8333-333333333333") is False
assert not responses.calls
@override_settings(
DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC="drive-minio:9000"
)
@responses.activate
def test_push_recording_rewrites_the_upload_host(
recording_with_token, mocked_download_url, upload
):
"""The upload reaches the internal address while announcing the signed host.
The presigned signature covers the Host header, so it has to stay untouched
even when the address we connect to does not.
"""
register_download()
register_drive(upload=upload)
assert push_recording(str(recording_with_token.id)) is True
assert upload["url"].startswith(INTERNAL_UPLOAD_URL)
assert upload["headers"]["Host"] == "drive-storage.test:9100"
assert upload["body"] == RECORDING_CONTENT
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
@responses.activate
def test_push_recording_without_content_length(
recording_with_token, mocked_download_url, upload
):
"""A size-less download cannot be relayed, and is reported as a failure."""
register_download(with_content_length=False)
register_drive(upload=upload)
assert push_recording(str(recording_with_token.id)) is False
assert "body" not in upload
@override_settings(DRIVE_API_BASE_URL=None)
def test_push_recording_without_drive_configured(
recording_with_token, mocked_download_url
):
"""An unconfigured Drive is reported, not raised."""
assert push_recording(str(recording_with_token.id)) is False
def test_recording_access_token_roundtrip():
"""The parked token is encrypted at rest and read back as-is."""
recording = factories.RecordingFactory()
recording.set_owner_access_token("user-access-token")
recording.refresh_from_db()
assert recording.owner_access_token != "user-access-token"
assert recording.get_owner_access_token() == "user-access-token"
recording.clear_owner_access_token()
recording.refresh_from_db()
assert recording.get_owner_access_token() is None
def test_recording_access_token_undecryptable():
"""A token encrypted with another key is reported as missing, not raised."""
recording = factories.RecordingFactory(owner_access_token="not-a-fernet-token")
assert recording.get_owner_access_token() is None
@pytest.mark.parametrize("enabled", [True, False])
def test_notify_external_services_schedules_the_push(enabled):
"""The push is scheduled from the notification flow, when enabled."""
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
recording.set_owner_access_token("user-access-token")
with (
override_settings(RECORDING_PUSH_TO_DRIVE_ENABLED=enabled),
mock.patch("core.recording.event.notification.push_recording") as mocked_push,
mock.patch.object(
NotificationService, "_notify_user_by_email", return_value=True
),
):
NotificationService().notify_external_services(recording)
assert mocked_push.delay.called is enabled
def test_notify_external_services_without_parked_token():
"""No token means nothing to push with, so no task is scheduled."""
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
with (
override_settings(RECORDING_PUSH_TO_DRIVE_ENABLED=True),
mock.patch("core.recording.event.notification.push_recording") as mocked_push,
mock.patch.object(
NotificationService, "_notify_user_by_email", return_value=True
),
):
NotificationService().notify_external_services(recording)
assert not mocked_push.delay.called
@@ -6,7 +6,7 @@ Test LiveKitEvents service.
import uuid
from unittest import mock
from django.test.utils import override_settings
from django.test import override_settings
import pytest
from livekit.api import EgressStatus
@@ -74,6 +74,8 @@ def test_initialization(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
# Without storage events, completion falls back to the egress event itself.
@override_settings(RECORDING_STORAGE_EVENT_ENABLE=False)
def test_handle_egress_ended_success(
mock_update_metadata, mock_notify, mode, notification_type, service
):
@@ -157,6 +159,8 @@ def test_handle_egress_updated_non_handled(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
# Without storage events, completion falls back to the egress event itself.
@override_settings(RECORDING_STORAGE_EVENT_ENABLE=False)
def test_handle_egress_ended_metadata_update_fails(
mock_update_metadata, mock_notify, mode, notification_type, service
):
@@ -553,23 +557,6 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_clear_cache.assert_not_called()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_ignores_connection_test_room(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should ignore room_finished events for connection test rooms."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
@@ -620,15 +607,6 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
service._handle_room_started(mock_data)
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
def test_handle_room_started_ignores_connection_test_room(service, settings):
"""Should ignore room_started events for connection test rooms."""
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
@@ -1,51 +0,0 @@
"""Tests for connection test Celery tasks."""
from unittest import mock
from django.test.utils import override_settings
from livekit.api import TwirpError
from core.tasks.connection_test import delete_connection_test_room
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.create_livekit_client")
def test_delete_connection_test_room_calls_livekit(mock_create_livekit_client):
"""DeleteRoom is called for rooms with the connection-test prefix."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock()
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
delete_connection_test_room("connection-test-abc")
mock_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0]
assert request.room == "connection-test-abc"
mock_api.aclose.assert_awaited_once()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.create_livekit_client")
def test_delete_connection_test_room_refuses_other_rooms(mock_create_livekit_client):
"""Refuse to delete rooms outside the connection-test namespace."""
delete_connection_test_room("production-room")
mock_create_livekit_client.assert_not_called()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.create_livekit_client")
def test_delete_connection_test_room_ignores_missing_room(mock_create_livekit_client):
"""Missing rooms are treated as already cleaned up."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("not_found", "room not found", status=404)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
delete_connection_test_room("connection-test-gone")
mock_api.aclose.assert_awaited_once()
@@ -1,94 +0,0 @@
"""Test connection test API endpoint."""
import uuid
from unittest import mock
from django.test.utils import override_settings
import jwt
import pytest
from rest_framework.test import APIClient
from core.api.connection_test import CONNECTION_TEST_USERNAME
pytestmark = pytest.mark.django_db
@override_settings(
CONNECTION_TEST_TOKEN_TTL_SECONDS=600,
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
)
def test_api_connection_test_returns_ephemeral_livekit_config():
"""Each request gets a dedicated room and a short-lived token."""
client = APIClient()
response_a = client.get("/api/v1.0/connection-test/")
response_b = client.get("/api/v1.0/connection-test/")
assert response_a.status_code == 200
assert response_b.status_code == 200
data_a = response_a.json()
data_b = response_b.json()
room_a = data_a["livekit"]["room"]
room_b = data_b["livekit"]["room"]
assert room_a.startswith("connection-test-")
assert room_b.startswith("connection-test-")
uuid.UUID(room_a.removeprefix("connection-test-"))
uuid.UUID(room_b.removeprefix("connection-test-"))
assert room_a != room_b
assert data_a["livekit"]["url"]
assert data_a["livekit"]["token"]
assert data_a["livekit"]["expires_in"] == 600
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300)
def test_api_connection_test_token_is_short_lived_for_user(settings):
"""Connection test tokens expire quickly for users."""
client = APIClient()
response = client.get("/api/v1.0/connection-test/")
assert response.status_code == 200
config = response.json()["livekit"]
payload = jwt.decode(
config["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert config["expires_in"] == 300
assert payload["video"]["room"] == config["room"]
assert payload["name"] == CONNECTION_TEST_USERNAME
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@override_settings(
CELERY_ENABLED=True,
CONNECTION_TEST_ROOM_MAX_AGE_SECONDS=300,
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
)
@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async")
def test_api_connection_test_schedules_room_deletion(mock_apply_async):
"""When Celery is enabled, schedule a hard room delete after max age."""
client = APIClient()
response = client.get("/api/v1.0/connection-test/")
assert response.status_code == 200
room = response.json()["livekit"]["room"]
mock_apply_async.assert_called_once_with(args=[room], countdown=300)
@override_settings(CELERY_ENABLED=False)
@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async")
def test_api_connection_test_skips_room_deletion_without_celery(mock_apply_async):
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
client = APIClient()
response = client.get("/api/v1.0/connection-test/")
assert response.status_code == 200
mock_apply_async.assert_not_called()
-6
View File
@@ -8,7 +8,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.api.connection_test import get_connection_test_config
from core.external_api import viewsets as external_viewsets
# - Main endpoints
@@ -47,11 +46,6 @@ urlpatterns = [
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path(
"connection-test/",
get_connection_test_config,
name="connection_test",
),
]
),
),
+39 -5
View File
@@ -12,12 +12,12 @@ import mimetypes
import random
import secrets
import string
from datetime import timedelta
from functools import lru_cache
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,6 +26,7 @@ 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,
@@ -68,7 +69,6 @@ def generate_token(
sources: Optional[List[str]] = None,
role: Optional[str] = None,
participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -84,7 +84,6 @@ def generate_token(
role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
Returns:
str: The LiveKit JWT access token.
@@ -138,8 +137,6 @@ def generate_token(
}
)
)
if ttl is not None:
token = token.with_ttl(ttl)
return token.to_jwt()
@@ -422,6 +419,43 @@ 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 cipher suite used to encrypt secrets at rest.
Deliberately the same key as django-lasuite's OIDC token storage, so a
deployment only has one key to provision for user credentials.
"""
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, was the key rotated?"
) from exc
def generate_download_s3_url(
key: str, *, expires_in: int, override_domain: bool = True
):
+37 -20
View File
@@ -349,11 +349,6 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"connection_test": values.Value(
default="30/minute",
environ_name="CONNECTION_TEST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -573,6 +568,20 @@ 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 parked 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
)
@@ -660,21 +669,6 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
300,
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
environ_prefix=None,
)
CONNECTION_TEST_ROOM_MAX_AGE_SECONDS = values.PositiveIntegerValue(
300,
environ_name="CONNECTION_TEST_ROOM_MAX_AGE_SECONDS",
environ_prefix=None,
)
CONNECTION_TEST_ROOM_PREFIX = values.Value(
"connection-test-",
environ_name="CONNECTION_TEST_ROOM_PREFIX",
environ_prefix=None,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
@@ -740,6 +734,29 @@ 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 main workspace of the user who
# 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://drive.example.com/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
@@ -1,13 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
export type ConnectionTestTokenResponse = {
livekit: {
url: string
room: string
token: string
expires_in: number
}
}
export const fetchConnectionTestToken = () =>
fetchApi<ConnectionTestTokenResponse>('/connection-test/')
@@ -1,245 +0,0 @@
import { useRef, useState } from 'react'
import {
CheckStatus,
ConnectionCheck,
createLocalAudioTrack,
createLocalVideoTrack,
getBrowser,
type CheckInfo,
type LocalVideoTrack,
} from 'livekit-client'
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
import {
createInitialSteps,
type ConnectionTestLog,
type ConnectionTestStepId,
type ConnectionTestStepResult,
type ConnectionTestStepStatus,
} from '../types'
import { openPermissionsDialog } from '@/stores/permissions'
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
'websocket',
'webrtc',
'turn',
'reconnect',
'publishAudio',
'publishVideo',
]
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
[CheckStatus.IDLE]: 'pending',
[CheckStatus.RUNNING]: 'running',
[CheckStatus.SUCCESS]: 'success',
[CheckStatus.FAILED]: 'failed',
[CheckStatus.SKIPPED]: 'skipped',
}
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
error instanceof Error ? error.message : fallback
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
summary: info.description,
logs: info.logs,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
const grouped: Record<MediaDeviceKind, string[]> = {
audioinput: [],
audiooutput: [],
videoinput: [],
}
for (const device of devices) {
grouped[device.kind].push(device.label || device.deviceId)
}
return grouped
}
export const useConnectionTestRunner = () => {
const [steps, setSteps] = useState(createInitialSteps)
const [isRunning, setIsRunning] = useState(false)
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
const abortRef = useRef<AbortController | null>(null)
const updateStep = (
id: ConnectionTestStepId,
patch: Partial<ConnectionTestStepResult>
) => {
setSteps((current) =>
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
)
}
const stopVideoTrack = () => {
videoTrackRef.current?.stop()
videoTrackRef.current = null
setVideoTrack(null)
}
const skipSteps = (
ids: ConnectionTestStepId[],
summary: string,
logs?: ConnectionTestLog[]
) => {
for (const id of ids) {
updateStep(id, { status: 'skipped', summary, logs })
}
}
/** Returns true on success, false on failure, null if aborted. */
const runStep = async (
id: ConnectionTestStepId,
signal: AbortSignal,
fn: () => Promise<Partial<ConnectionTestStepResult>>
): Promise<boolean | null> => {
if (signal.aborted) return null
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
try {
const result = await fn()
if (signal.aborted) return null
// `result.status` overrides when set (LiveKit checks map their own status)
updateStep(id, { status: 'success', ...result })
return true
} catch (error) {
if (signal.aborted) return null
updateStep(id, {
status: 'failed',
summary: getErrorMessage(error),
})
return false
}
}
const runTest = async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const { signal } = controller
setIsRunning(true)
setSteps(createInitialSteps())
stopVideoTrack()
try {
await runStep('browser', signal, async () => {
const browser = getBrowser()
if (!browser) throw new Error('Browser not detected')
return {
summary: `${browser.name} ${browser.version}`,
data: {
name: browser.name,
version: browser.version,
os: browser.os,
osVersion: browser.osVersion,
},
}
})
if (signal.aborted) return
const microphoneOk = await runStep('microphone', signal, async () => {
const track = await createLocalAudioTrack()
const label =
track.mediaStreamTrack.label ||
track.mediaStreamTrack.getSettings().deviceId ||
''
track.stop()
return { summary: label, data: { label } }
})
if (signal.aborted) return
if (!microphoneOk) openPermissionsDialog('audioinput')
const cameraOk = await runStep('camera', signal, async () => {
const track = await createLocalVideoTrack()
videoTrackRef.current = track
setVideoTrack(track)
const settings = track.mediaStreamTrack.getSettings()
const label = track.mediaStreamTrack.label || ''
return {
summary: label,
data: {
label,
width: settings.width,
height: settings.height,
},
}
})
if (signal.aborted) return
if (!cameraOk) openPermissionsDialog('videoinput')
await runStep('devices', signal, async () => {
const devices = await navigator.mediaDevices.enumerateDevices()
return {
summary: String(devices.length),
data: groupDevicesByKind(devices),
}
})
if (signal.aborted) return
let checker: ConnectionCheck
try {
const { livekit } = await fetchConnectionTestToken()
checker = new ConnectionCheck(livekit.url, livekit.token)
} catch (error) {
skipSteps(
LIVEKIT_STEP_IDS,
getErrorMessage(error, 'Failed to fetch test token')
)
return
}
await runStep('websocket', signal, async () =>
fromCheckInfo(await checker.checkWebsocket())
)
await runStep('webrtc', signal, async () =>
fromCheckInfo(await checker.checkWebRTC())
)
await runStep('turn', signal, async () =>
fromCheckInfo(await checker.checkTURN())
)
await runStep('reconnect', signal, async () =>
fromCheckInfo(await checker.checkReconnect())
)
if (!microphoneOk) {
skipSteps(['publishAudio'], 'Microphone permission required')
} else {
await runStep('publishAudio', signal, async () =>
fromCheckInfo(await checker.checkPublishAudio())
)
}
if (!cameraOk) {
skipSteps(['publishVideo'], 'Camera permission required')
} else {
stopVideoTrack()
await runStep('publishVideo', signal, async () =>
fromCheckInfo(await checker.checkPublishVideo())
)
}
} finally {
if (!signal.aborted) {
stopVideoTrack()
setIsRunning(false)
}
}
}
const reset = () => {
abortRef.current?.abort()
stopVideoTrack()
setSteps(createInitialSteps())
setIsRunning(false)
}
return {
steps,
isRunning,
videoTrack,
runTest,
reset,
}
}
@@ -1,156 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { Box, Button, Text, Ul } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { Center, HStack, VStack } from '@/styled-system/jsx'
import { Permissions } from '@/features/rooms/components/Permissions'
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
const { t } = useTranslation('connectionTest')
const [showDetails, setShowDetails] = useState(false)
const hasLogs = Boolean(step.logs?.length)
const statusLabel = t(`status.${step.status}`)
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
const statusVariant =
step.status === 'failed'
? 'warning'
: step.status === 'success'
? 'body'
: 'smNote'
return (
<VStack gap="0.25rem" alignItems="stretch" width="100%">
<HStack
justifyContent="space-between"
alignItems="flex-start"
width="100%"
>
<Text variant="bodyXsMedium">{stepLabel}</Text>
{step.status === 'running' ? (
<Spinner size={20} />
) : (
<Text variant={statusVariant}>{statusLabel}</Text>
)}
</HStack>
{step.summary && (
<Text variant="smNote" margin={false}>
{step.summary}
</Text>
)}
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
<Button
variant="secondaryText"
size="sm"
onPress={() => setShowDetails((open) => !open)}
>
{t('details')}
</Button>
)}
{showDetails && step.logs && (
<Ul>
{step.logs.map((log, index) => (
<li key={index}>
<Text variant="xsNote" as="span">
{log.message}
</Text>
</li>
))}
</Ul>
)}
</VStack>
)
}
const ConnectionTest = () => {
const { t } = useTranslation('connectionTest')
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
const videoRef = useRef<HTMLVideoElement>(null)
const hasStarted = steps.some((step) => step.status !== 'pending')
const hasFailed = steps.some((step) => step.status === 'failed')
const isPublishVideoRunning = steps.some(
(step) => step.id === 'publishVideo' && step.status === 'running'
)
useEffect(() => {
const element = videoRef.current
if (!element || !videoTrack) return
videoTrack.attach(element)
return () => {
videoTrack.detach(element)
}
}, [videoTrack])
// LiveKit appends a bare <video> to document.body during publishVideo.
// Keep it in the DOM (so the frame check still works) but hide it visually.
useEffect(() => {
document.body.classList.toggle(
HIDE_LIVEKIT_VIDEO_CLASS,
isPublishVideoRunning
)
return () => {
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
}
}, [isPublishVideoRunning])
return (
<Screen layout="centered">
<Permissions />
<CenteredContent title={t('title')} withBackButton>
<Center>
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
<Text as="p" variant="paragraph" centered last>
{t('intro')}
</Text>
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
{hasStarted ? t('reset') : t('runTest')}
</Button>
{videoTrack && (
<Center>
<video ref={videoRef} autoPlay playsInline muted />
</Center>
)}
{hasStarted && (
<Box variant="light" width="100%">
<VStack gap="1rem" alignItems="stretch">
{steps.map((step) => (
<TestStepItem key={step.id} step={step} />
))}
</VStack>
</Box>
)}
{hasFailed && !isRunning && (
<Text variant="smNote" centered>
{t('help.firewall')}
</Text>
)}
{hasStarted && !isRunning && (
<Button
variant="secondary"
onPress={() => downloadConnectionTestReport(steps)}
>
{t('downloadReport')}
</Button>
)}
</VStack>
</Center>
</CenteredContent>
</Screen>
)
}
export default ConnectionTest
@@ -1,47 +0,0 @@
export type ConnectionTestStepId =
| 'browser'
| 'microphone'
| 'camera'
| 'devices'
| 'websocket'
| 'webrtc'
| 'turn'
| 'reconnect'
| 'publishAudio'
| 'publishVideo'
export type ConnectionTestStepStatus =
| 'pending'
| 'running'
| 'success'
| 'failed'
| 'skipped'
export type ConnectionTestLog = {
level: 'info' | 'warning' | 'error'
message: string
}
export type ConnectionTestStepResult = {
id: ConnectionTestStepId
status: ConnectionTestStepStatus
summary?: string
logs?: ConnectionTestLog[]
data?: Record<string, unknown>
}
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
'browser',
'microphone',
'camera',
'devices',
'websocket',
'webrtc',
'turn',
'reconnect',
'publishAudio',
'publishVideo',
]
export const createInitialSteps = (): ConnectionTestStepResult[] =>
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
@@ -1,49 +0,0 @@
import type { ConnectionTestStepResult } from '../types'
export type ConnectionTestReport = {
generatedAt: string
userAgent: string
steps: Record<
string,
{
status: ConnectionTestStepResult['status']
summary?: string
logs?: ConnectionTestStepResult['logs']
data?: ConnectionTestStepResult['data']
}
>
}
export const buildConnectionTestReport = (
steps: ConnectionTestStepResult[]
): ConnectionTestReport => ({
generatedAt: new Date().toISOString(),
userAgent: navigator.userAgent,
steps: Object.fromEntries(
steps.map(({ id, status, summary, logs, data }) => [
id,
{
status,
...(summary !== undefined ? { summary } : {}),
...(logs?.length ? { logs } : {}),
...(data !== undefined ? { data } : {}),
},
])
),
})
export const downloadConnectionTestReport = (
steps: ConnectionTestStepResult[]
) => {
const report = buildConnectionTestReport(steps)
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
const blob = new Blob([JSON.stringify(report, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `connection-test-${timestamp}.json`
anchor.click()
URL.revokeObjectURL(url)
}
-10
View File
@@ -266,16 +266,6 @@ export const Footer = () => {
{t('links.accessibility')}
</Link>
</StyledLi>
<StyledLi divider>
<Link
underline={false}
footer="minor"
to="/test-connection"
aria-label={t('links.connectionTest')}
>
{t('links.connectionTest')}
</Link>
</StyledLi>
<StyledLi>
<A
externalIcon
@@ -1,31 +0,0 @@
{
"title": "Test your configuration",
"intro": "Check that your device works with Visio: browser, media devices, and server connectivity.",
"runTest": "Run test",
"reset": "Run again",
"details": "Details",
"downloadReport": "Download report",
"homeLink": "Test your configuration",
"steps": {
"browser": "Browser",
"microphone": "Microphone",
"camera": "Camera",
"devices": "Media devices",
"websocket": "WebSocket",
"webrtc": "WebRTC",
"turn": "TURN",
"reconnect": "Reconnect",
"publishAudio": "Audio publishing",
"publishVideo": "Video publishing"
},
"status": {
"pending": "Pending",
"running": "Running…",
"success": "Passed",
"failed": "Failed",
"skipped": "Skipped"
},
"help": {
"firewall": "If network tests fail, check your browser permissions and network filtering rules (WebRTC, WebSocket, TURN) with your IT department."
}
}
-1
View File
@@ -36,7 +36,6 @@
"legalsTerms": "Legal Notice",
"data": "Personal Data and Cookies",
"accessibility": "Accessibility: non-compliant",
"connectionTest": "Test your configuration",
"ariaLabel": "new window",
"codeAnnotation": "Our code is open and available on this",
"code": "Open Source Code Repository",
-1
View File
@@ -13,7 +13,6 @@
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
"moreLink": "Learn more",
"moreAbout": "about {{appTitle}}",
"connectionTestLink": "Test your configuration",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
@@ -1,31 +0,0 @@
{
"title": "Tester votre configuration",
"intro": "Vérifiez la compatibilité de votre poste avec Visio : navigateur, périphériques médias et connexion au serveur.",
"runTest": "Lancer le test",
"reset": "Relancer",
"details": "Détails",
"downloadReport": "Télécharger le rapport",
"homeLink": "Tester votre configuration",
"steps": {
"browser": "Navigateur",
"microphone": "Microphone",
"camera": "Caméra",
"devices": "Périphériques médias",
"websocket": "WebSocket",
"webrtc": "WebRTC",
"turn": "TURN",
"reconnect": "Reconnexion",
"publishAudio": "Publication audio",
"publishVideo": "Publication vidéo"
},
"status": {
"pending": "En attente",
"running": "En cours…",
"success": "Réussi",
"failed": "Échec",
"skipped": "Ignoré"
},
"help": {
"firewall": "En cas d'échec des tests réseau, vérifiez vos permissions navigateur et les règles de filtrage réseau (WebRTC, WebSocket, TURN) auprès de votre service informatique."
}
}
-1
View File
@@ -36,7 +36,6 @@
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilité : non conforme",
"connectionTest": "Tester votre configuration",
"ariaLabel": "nouvelle fenêtre",
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
"code": "dépôt de code Open Source",
-1
View File
@@ -13,7 +13,6 @@
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur {{appTitle}}",
"connectionTestLink": "Tester votre configuration",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
-9
View File
@@ -20,9 +20,6 @@ const AccessibilityRoute = lazy(
)
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
const ConnectionTestRoute = lazy(
() => import('@/features/connection-test/routes/ConnectionTest')
)
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
@@ -30,7 +27,6 @@ export const routes: Record<
| 'home'
| 'room'
| 'feedback'
| 'connectionTest'
| 'legalTerms'
| 'accessibility'
| 'termsOfService'
@@ -61,11 +57,6 @@ export const routes: Record<
path: '/feedback',
Component: FeedbackRoute,
},
connectionTest: {
name: 'connectionTest',
path: '/test-connection',
Component: ConnectionTestRoute,
},
legalTerms: {
name: 'legalTerms',
path: '/mentions-legales',
-13
View File
@@ -31,19 +31,6 @@ html.font-opendyslexic {
border: 0;
}
/* LiveKit ConnectionCheck appends a temporary <video> to body during publishVideo.
Keep it decodable (not display:none) but invisible. */
body.connection-test-hide-livekit-video > video {
position: fixed;
top: 0;
left: 0;
width: 10px;
height: 10px;
opacity: 0;
pointer-events: none;
z-index: -1;
}
* {
outline: 2px solid transparent;
}