mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-30 05:38:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b92ae8f73 | |||
| ac2b5bd4f3 | |||
| 7c92f6054b |
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Celery-optional task decorator."""
|
||||
|
||||
# ruff: noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user