mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-29 21:28:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b92ae8f73 | |||
| ac2b5bd4f3 | |||
| 7c92f6054b |
@@ -10,6 +10,10 @@ 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
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -198,37 +198,3 @@ class IsPresentInMeeting(permissions.BasePermission):
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
|
||||
class RoomCapabilityPermission(permissions.BasePermission):
|
||||
"""Wip."""
|
||||
|
||||
capability = None
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Wip."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if obj.is_administrator_or_owner(user):
|
||||
return True
|
||||
|
||||
if not obj.configuration.get(self.capability, True):
|
||||
return False
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
|
||||
class CanRecord(RoomCapabilityPermission):
|
||||
"""Wip."""
|
||||
|
||||
capability = "authenticated_can_record"
|
||||
|
||||
@@ -330,7 +330,6 @@ class RoomConfiguration(BaseModel):
|
||||
|
||||
can_publish_sources: list[TrackSource] | None = None
|
||||
everyone_can_mute: bool | None = None
|
||||
authenticated_can_record: bool | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
@@ -364,12 +364,39 @@ 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"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.CanRecord,
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
@@ -394,30 +421,12 @@ class RoomViewSet(
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
|
||||
if room.is_administrator_or_owner(request.user):
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
else:
|
||||
# todo - encapsulate it in a clear method which replicate privileges role on obj
|
||||
accesses = models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).values_list("user_id", "role")
|
||||
|
||||
models.RecordingAccess.objects.bulk_create(
|
||||
[
|
||||
models.RecordingAccess(
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
recording=recording,
|
||||
)
|
||||
for user_id, role in accesses
|
||||
]
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
self._park_drive_credentials(request, recording)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -461,7 +470,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.CanRecord,
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
|
||||
@@ -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={})
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Asynchronous tasks of the core application.
|
||||
|
||||
Importing the task modules here is what makes Celery's `autodiscover_tasks`
|
||||
register them: it only imports the `core.tasks` package itself, never its
|
||||
submodules.
|
||||
"""
|
||||
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
__all__ = ["process_file_deletion", "push_recording"]
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Celery-optional task decorator."""
|
||||
|
||||
# ruff: noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -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,6 +6,8 @@ Test LiveKitEvents service.
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
@@ -72,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
|
||||
):
|
||||
@@ -155,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
|
||||
):
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
@@ -25,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,
|
||||
@@ -417,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
|
||||
):
|
||||
|
||||
@@ -568,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
|
||||
)
|
||||
@@ -720,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
|
||||
|
||||
@@ -98,7 +98,6 @@ export const NoAccessView = ({
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
{/*todo - update the message here to adapt to room configuration */}
|
||||
{!isLoggedIn && (
|
||||
<LoginPrompt
|
||||
heading={t(`${i18nKey}.login.heading`)}
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { useCanRecord } from '@/features/recording/hooks/useCanRecord'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -39,7 +38,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
@@ -112,7 +110,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!canRecord) {
|
||||
if (!isAdminOrOwner) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useHasFeatureWithoutRecordingRights,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useRecordingStatuses,
|
||||
} from '../index'
|
||||
import { useState } from 'react'
|
||||
@@ -53,7 +53,7 @@ export const TranscriptSidePanel = () => {
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutRecordingRights(
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
|
||||
export const useCanRecord = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
return (
|
||||
isAdminOrOwner ||
|
||||
(!!isLoggedIn &&
|
||||
apiRoomData?.configuration?.authenticated_can_record !== false)
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFeatureWithoutRecordingRights = (
|
||||
export const useHasFeatureWithoutAdminRights = (
|
||||
mode: RecordingMode,
|
||||
featureFlag: FeatureFlags
|
||||
) => {
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isRecordingModeEnabled &&
|
||||
!canRecord
|
||||
!isAdminOrOwner
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
|
||||
export const useHasRecordingAccess = (
|
||||
mode: RecordingMode,
|
||||
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
canRecord &&
|
||||
isAdminOrOwner &&
|
||||
isRecordingModeEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights.ts'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||
|
||||
// api
|
||||
|
||||
@@ -16,7 +16,6 @@ export enum ApiAccessLevel {
|
||||
export type RoomConfiguration = {
|
||||
can_publish_sources?: Source[] | null
|
||||
everyone_can_mute?: boolean | null
|
||||
authenticated_can_record?: boolean | null
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
|
||||
@@ -50,12 +50,7 @@ export const Admin = () => {
|
||||
isScreenShareEnabled,
|
||||
} = usePublishSourcesManager()
|
||||
|
||||
const {
|
||||
toggleMuting,
|
||||
isMutingEnabled,
|
||||
isAuthenticatedCanRecordEnabled,
|
||||
toggleAuthenticatedCanRecord,
|
||||
} = usePermissionsManager()
|
||||
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
|
||||
|
||||
return (
|
||||
<Div
|
||||
@@ -160,17 +155,6 @@ export const Admin = () => {
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
type="switch"
|
||||
label={'Authenticated can record'}
|
||||
description={'Authenticated can record'}
|
||||
isSelected={isAuthenticatedCanRecordEnabled}
|
||||
onChange={toggleAuthenticatedCanRecord}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -12,8 +12,6 @@ export const usePermissionsManager = () => {
|
||||
const roomId = data?.slug
|
||||
|
||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
||||
const isAuthenticatedCanRecordEnabled =
|
||||
configuration?.authenticated_can_record ?? true
|
||||
|
||||
const toggleMuting = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
@@ -41,36 +39,8 @@ export const usePermissionsManager = () => {
|
||||
[configuration, roomId, patchRoom]
|
||||
)
|
||||
|
||||
const toggleAuthenticatedCanRecord = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!roomId) return
|
||||
|
||||
try {
|
||||
const newConfiguration = {
|
||||
...configuration,
|
||||
authenticated_can_record: enabled,
|
||||
}
|
||||
|
||||
const room = await patchRoom({
|
||||
roomId,
|
||||
room: { configuration: newConfiguration },
|
||||
})
|
||||
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
|
||||
return { configuration: newConfiguration }
|
||||
} catch (error) {
|
||||
console.error('Failed to update recording permission:', error)
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
[configuration, roomId, patchRoom]
|
||||
)
|
||||
|
||||
return {
|
||||
toggleMuting,
|
||||
isMutingEnabled,
|
||||
isAuthenticatedCanRecordEnabled,
|
||||
toggleAuthenticatedCanRecord,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user