Compare commits

..

17 Commits

Author SHA1 Message Date
lebaudantoine e3aafc5a59 fixup! 🔒️(backend) throttle meeting link generation 2026-09-29 16:22:10 +02:00
lebaudantoine 63d8fb995a 🔒️(backend) add a daily cap on room creation
The per-minute room creation throttle absorbs bursts but does not stop
a compromised account from steadily creating rooms over hours or days.

Add RoomCreationDailyUserRateThrottle, a per-user throttle with its own
"room_creation_daily" scope, applied to room creation only alongside
the existing short-term throttle. It defaults to 1000 rooms per day and
is configurable via ROOM_CREATION_DAILY_THROTTLE_RATES.

Tests use a controllable clock and patch rates with monkeypatch so they
are restored after each test.
2026-09-29 16:22:08 +02:00
lebaudantoine cd66254281 fixup! 🔒️(backend) throttle meeting link generation 2026-09-29 16:21:54 +02:00
Lebaud Antoine 352d9ada6a 🔒️(backend) throttle meeting link generation
Add throttling on the endpoint used to generate meeting links, so a
compromised authenticated account cannot silently generate thousands
of links without hitting any suspicious errors or alerts.

The limits are set high enough not to affect legitimate usage, while
capping the damage a leaked account can do.
2026-09-29 16:21:51 +02:00
leo 4071984f8e ⬆️(dependencies) update python dependencies
Update python dependencies.

Co-Authored-By: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 11:47:32 +02:00
lebaudantoine 2ae602606c ⚡️(frontend) disable posthog-js periodic feature flag reloads
Since posthog-js 1.356.0, feature flags are reloaded every 5
minutes by default while the tab is visible. Meet sessions are
long-lived visible tabs, so this multiplied the number of `/flags`
requests we send.

Restore the pre-1.356.0 behavior: only (re)load flags on init and
on identity
2026-09-29 11:14:05 +02:00
briquet f28389b624 👷(helm) run the inactive rooms purge command as cronjob
Schedule `purge_inactive_rooms` every night along with the other
housekeeping commands.
2026-09-29 11:08:38 +02:00
briquet cbb8740f41 📝(docs) document the inactive rooms purge
Add a basic documentation of the purge_inactive_rooms command
2026-09-29 11:08:38 +02:00
briquet b4b9fe54fb ♻️(backend) add command to purge inactive rooms
Add a `purge_inactive_rooms` management command which permanently
deletes the rooms that were not started for
`ROOM_INACTIVITY_DELETION_DAYS` days. Rooms never started are aged from
their creation date.

Important points :
- The setting is unset by default, which disables the purge.
- Rooms holding a recording their users may still access are kept,
- It refuses to run while unregistered rooms are allowed, as the link
  of a purged room would turn into a public unregistered room,
2026-09-29 11:08:38 +02:00
briquet 88685d613a 🧐(backend) track room last start datetime from livekit webhook
Rooms pile up in database with no way to tell the ones still in use from
the abandoned ones. Record on the room the last time LiveKit reported it
as started, in a new `last_started_at` field which stays NULL until the
first time the room is started on a LiveKit node.

The migration stamps existing rooms with the migration time to avoid
deleting preexisting rooms.
2026-09-29 11:08:38 +02:00
briquet 6cde1b4461 🔨(dev) add Makefile targets to list and download files from Garage
Garage has no web console, so inspecting recordings, transcripts and
summaries locally meant writing aws-cli commands by hand.

For each of recordings, transcripts and summaries:

- `make <folder>-list` lists the files from the most recent, and
- `make <folder>-download-latest` downloads the latest one into
data/<folder>.

Only files with the folder's expected extensions are kept, so the Egress
manifests stored next to recordings are skipped.
2026-09-29 10:52:49 +02:00
briquet 68fe3f96fc 🔧(helm) point media services to Garage by default
The media ingresses and their ExternalName services defaulted to the
MinIO service of the development stack, which is now Garage.
Self-hosted relying on these defaults must set serviceMedia*.host and the
upstream-vhost annotations explicitly, see UPGRADE.md.
2026-09-29 10:52:49 +02:00
briquet 3f9942a61d 🔧(compose) replace MinIO by Garage for local development
The development stacks now run Garage instead of MinIO which is
deprecated.

Garage is a bit stricter than MinIO:
- key IDs and secrets must be at least 8 and 16 characters long
- requests must be signed for its region, so every development env now sets
  AWS_S3_REGION_NAME=local;
- cross-origin requests are denied unless the bucket CORS rules allow
  them, so a one-shot aws-cli container allows the frontend origin to
  upload files straight to the bucket.
2026-09-29 10:52:49 +02:00
briquet 62a2515c6b ✅(summary) test the S3 FileClient service
Ensure that the new combination of boto + Garage work well
together and honor the region and secure parameters.
2026-09-29 10:52:49 +02:00
briquet fa9b30c6bf ♻️(agents) replace the minio client by boto3
Switch from the minio client to the boto3 python client, in the metadata
collector agent and the summary service. AWS_S3_REGION_NAME is passed
as-is to boto3, the region is not looked up from the bucket.
2026-09-29 10:52:49 +02:00
lebaudantoine 4d9ee4e9c5 🔧(devx) call summary v2 without trailing slash from dev backend
In the dev stack, configure the backend to call the summary service
using its v2 API by default.

Also strip the trailing `/` from the task endpoint, which was
triggering a redirect on every call.
2026-09-29 10:40:28 +02:00
lebaudantoine 72cd5a8f18 🔥(github) remove local GitHub PR and issue templates
The organization now provides default GitHub templates for pull
requests and issues at the org level, so individual repositories no
longer need to duplicate the same Markdown files.

Delete the local templates so this project uses the organization
defaults, reducing the amount of code we maintain and centralizing
the practice across repositories.
2026-09-28 17:03:41 +02:00
64 changed files with 3814 additions and 3246 deletions
-28
View File
@@ -1,28 +0,0 @@
---
name: 🐛 Bug Report
about: If something is not working as expected 🤔.
---
## Bug Report
**Problematic behavior**
A clear and concise description of the behavior.
**Expected behavior/code**
A clear and concise description of what you expected to happen (or code).
**Steps to Reproduce**
1. Do this...
2. Then this...
3. And then the bug happens!
**Environment**
- Meet version:
- Platform:
**Possible Solution**
<!--- Only if you have suggestions on a fix for the bug -->
**Additional context/Screenshots**
Add any other context about the problem here. If applicable, add screenshots to help explain.
-23
View File
@@ -1,23 +0,0 @@
---
name: ✨ Feature Request
about: I have a suggestion (and may want to build it 💪)!
---
## Feature Request
**Is your feature request related to a problem or unsupported use case? Please describe.**
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
**Describe the solution you'd like**
A clear and concise description of what you want to happen. Add any considered drawbacks.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Discovery, Documentation, Adoption, Migration Strategy**
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
Maybe a screenshot or design?
**Do you want to work on it through a Pull Request?**
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
@@ -1,22 +0,0 @@
---
name: 🤗 Support Question
about: If you have a question 💬, or something was not clear from the docs!
---
<!-- ^ Click "Preview" for a nicer view! ^
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
**Topic**
What's the general area of your question: for example, docker setup, database schema, search functionality,...
**Question**
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
-11
View File
@@ -1,11 +0,0 @@
## Purpose
Description...
## Proposal
Description...
- [] item 1...
- [] item 2...
+18 -29
View File
@@ -226,8 +226,9 @@ jobs:
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_REGION_NAME: local
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
@@ -250,34 +251,22 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
# Creates the access key and the bucket on startup
- name: Start Garage
run: |
docker pull quay.io/minio/minio
docker run -d --name minio \
docker run -d --name garage \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
quay.io/minio/minio server --console-address :9001 /data
-v "${GITHUB_WORKSPACE}/docker/files/etc/garage/garage.toml:/etc/garage.toml:ro" \
-e "GARAGE_RPC_SECRET=$(openssl rand -hex 32)" \
-e "GARAGE_DEFAULT_ACCESS_KEY=meet-access-key" \
-e "GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key" \
-e "GARAGE_DEFAULT_BUCKET=meet-media-storage" \
dxflrs/garage:v2.4.1 \
/garage server --single-node --default-bucket
# Tool to wait for a service to be ready
- name: Install Dockerize
- name: Wait for Garage to be ready
run: |
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -310,9 +299,9 @@ jobs:
env:
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_ENDPOINT_URL: "garage:9000"
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
+14 -2
View File
@@ -8,13 +8,20 @@ and this project adheres to
## [Unreleased]
### Added
- 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation
### Changed
- ⚡️(backend) hash application secrets with SHA-256
- ⬆️(backend) update python dependencies
- ⬆️(summary) update python dependencies
- ⬆️(agents) update python dependencies
### Fixed
- 🔒️(backend) prevent editing client id and secret in Django admin
- ⚡️(frontend) disable posthog-js periodic feature flag reloads
## [1.32.1] - 2026-09-25
@@ -28,8 +35,10 @@ and this project adheres to
### Added
- ✨(backend) make the LiveKit default video codec configurable
- ✨(backend) purge rooms inactive for a configurable period
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
- 🔨(makefile) add targets to list and download files stored in Garage
### Changed
@@ -48,6 +57,9 @@ and this project adheres to
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
- 🔖(helm) release chart 0.0.28
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
- ♻️(agents) replace the minio client by boto3
- 🔧(compose) replace MinIO by Garage for local development
- 🔧(helm) point media services to Garage by default
### Fixed
+46
View File
@@ -69,6 +69,22 @@ LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT)
# -- Frontend
PATH_FRONT = ./src/frontend
# -- Storage
GARAGE_BUCKET = meet-media-storage
STORAGE_FOLDERS = recordings transcripts summaries
STORAGE_DIRS = $(addprefix data/,$(STORAGE_FOLDERS))
COMPOSE_RUN_AWS = $(COMPOSE_RUN) --user $(DOCKER_USER)
AWS_CLI = garage-cors --endpoint-url=http://garage:9000
# Extensions listed in each folder (skips the Egress manifests in recordings/)
recordings_EXTENSIONS = mp4 ogg
transcripts_EXTENSIONS = json
summaries_EXTENSIONS = txt
# $(1): folder. Lists its objects with a known extension, most recent first
storage_list = s3api list-objects-v2 --bucket $(GARAGE_BUCKET) \
--prefix $(1)/
storage_query = reverse(sort_by(Contents[?$(foreach ext,$($(1)_EXTENSIONS), \
ends_with(Key, `".$(ext)"`) ||) `false`] || `[]`, &LastModified))
# ==============================================================================
# RULES
@@ -77,6 +93,9 @@ default: help
data/media:
@mkdir -p data/media
$(STORAGE_DIRS):
@mkdir -p $@
data/static:
@mkdir -p data/static
@@ -85,6 +104,7 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/garage \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
@@ -317,12 +337,38 @@ env.d/development/summary:
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/garage:
sed "s/^GARAGE_RPC_SECRET=.*/GARAGE_RPC_SECRET=$$(openssl rand -hex 32)/" \
env.d/development/garage.dist > env.d/development/garage
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
env.d/development/metadata_collector:
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
# -- Storage
recordings-download-latest: ## download the latest recording from Garage into data/recordings
transcripts-download-latest: ## download the latest transcript from Garage into data/transcripts
summaries-download-latest: ## download the latest summary from Garage into data/summaries
$(STORAGE_FOLDERS:%=%-download-latest): %-download-latest: data/%
@key=$$($(COMPOSE_RUN_AWS) -T $(AWS_CLI) $(call storage_list,$*) \
--query '$(call storage_query,$*)[0].Key' --output text) && \
if [ "$$key" = "None" ]; then echo "No $* found"; exit 1; fi && \
$(COMPOSE_RUN_AWS) --volume $(CURDIR)/data/$*:/aws/data/$* \
$(AWS_CLI) s3 cp "s3://$(GARAGE_BUCKET)/$$key" data/$*/
.PHONY: $(STORAGE_FOLDERS:%=%-download-latest)
recordings-list: ## list recordings stored in Garage, most recent first
transcripts-list: ## list transcripts stored in Garage, most recent first
summaries-list: ## list summaries stored in Garage, most recent first
$(STORAGE_FOLDERS:%=%-list): %-list:
@$(COMPOSE_RUN_AWS) $(AWS_CLI) $(call storage_list,$*) \
--query '$(call storage_query,$*)[].{Date: LastModified, Key: Key, "Size (bytes)": Size}' \
--output table
.PHONY: $(STORAGE_FOLDERS:%=%-list)
# -- Internationalization
env.d/development/crowdin:
+38
View File
@@ -16,6 +16,44 @@ the following command inside your docker container:
## [Unreleased]
### Purging inactive rooms
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
### Local development: MinIO replaced by Garage
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
To migrate a local environment:
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
### Summary service and metadata collector: boto3 replaces the minio client
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
Also:
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
### Helm chart: media services default to Garage
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
## v1.30.0
### Removing S3 storage-event webhooks for recordings
+2 -2
View File
@@ -104,8 +104,8 @@ k8s_yaml(secret_yaml_generic(
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('garage-cors', resource_deps=['garage'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
+31 -23
View File
@@ -15,36 +15,44 @@ services:
ports:
- "1081:1080"
minio:
garage:
user: ${DOCKER_USER:-1000}
image: quay.io/minio/minio
image: dxflrs/garage:v2.4.1
command: /garage server --single-node --default-bucket
env_file:
- env.d/development/garage
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
- GARAGE_DEFAULT_BUCKET=meet-media-storage
ports:
- '9000:9000'
- '9001:9001'
- '127.0.0.1:9000:9000'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
test: [ "CMD", "/garage", "health" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
- ./data/media:/var/lib/garage
createbuckets:
image: quay.io/minio/mc
# Garage denies cross-origin requests by default: allow the frontend to upload files
garage-cors:
image: amazon/aws-cli:2.37.1
environment:
- AWS_ACCESS_KEY_ID=meet-access-key
- AWS_SECRET_ACCESS_KEY=meet-secret-access-key
- AWS_DEFAULT_REGION=local
depends_on:
minio:
garage:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
command:
- s3api
- put-bucket-cors
- --endpoint-url=http://garage:9000
- --bucket=meet-media-storage
- '--cors-configuration={"CORSRules": [{"AllowedOrigins": ["http://localhost:3000"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
app-dev:
build:
@@ -70,7 +78,7 @@ services:
- postgresql
- mailcatcher
- redis
- createbuckets
- garage-cors
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
@@ -110,7 +118,7 @@ services:
- postgresql
- redis
- livekit
- minio
- garage
celery:
user: ${DOCKER_USER:-1000}
@@ -244,7 +252,7 @@ services:
- /app/.venv
depends_on:
- livekit
- minio
- garage
develop:
watch:
- action: rebuild
@@ -297,7 +305,7 @@ services:
depends_on:
- redis-summary
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
@@ -317,7 +325,7 @@ services:
depends_on:
- redis-summary
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
+1 -1
View File
@@ -163,7 +163,7 @@ in
REDIS_URL = "redis://127.0.0.1:6379/1";
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
# S3 / MinIO
# S3 / Garage
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
# OIDC
+3 -1
View File
@@ -19,7 +19,9 @@ services:
<<: *keep-id
celery-dev:
<<: *keep-id
minio:
garage:
<<: *keep-id
garage-cors:
<<: *keep-id
node:
<<: *keep-id
+15
View File
@@ -0,0 +1,15 @@
# Garage configuration for local development only: single node, no replication.
# See https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
api_bind_addr = "[::]:9000"
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
s3_region = "local"
+3 -3
View File
@@ -17,9 +17,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from Garage
proxy_pass http://garage:9000/meet-media-storage/;
proxy_set_header Host garage:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
+1 -1
View File
@@ -13,7 +13,7 @@ These components rely on a few key services:
- PostgreSQL for storing data (users, rooms, recordings)
- Redis for caching and inter-service communication
- MinIO for storing files (room recordings)
- Garage for storing files (room recordings)
- Celery workers for meeting transcript (optional, required for AI beta features)
We provide two stack options for getting Visio up and running for development:
+40
View File
@@ -0,0 +1,40 @@
# Room purge
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
## How it works
Each time LiveKit tells the backend that a room has started (`room_started` webhook), the backend records the date on the room (`last_started_at`).
A room is inactive when:
- it was last started more than `ROOM_INACTIVITY_DELETION_DAYS` days ago, or
- it was never started and was created more than `ROOM_INACTIVITY_DELETION_DAYS` days ago.
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
```bash
python manage.py purge_inactive_rooms # delete the inactive rooms
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
```
## Rooms that are kept
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
## What happens to a purged room
The room is deleted from the database, along with its accesses, its telephony PIN code, and the recording entries it still holds — the expired ones and those that were never saved, since any other recording would have protected the room — together with their own accesses.
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
+8 -7
View File
@@ -42,7 +42,7 @@ sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant S3 as S3 (Object Storage)
participant STT as WhisperX API
participant Docs as LaSuite Docs
@@ -50,7 +50,7 @@ sequenceDiagram
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
Summary->>Celery: Register task (transcribe-queue)
Celery->>MinIO: Fetch audio file
Celery->>S3: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
@@ -72,11 +72,12 @@ sequenceDiagram
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
| aws_storage_bucket_name | String | — | Name of the S3 bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3 storage. |
| aws_s3_access_key_id | String | — | Access key for S3. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3 requests. |
| aws_s3_region_name | String | — | Region used to sign S3 requests, passed as-is to boto3. |
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
+1 -1
View File
@@ -14,7 +14,7 @@ All services are required to run the minimalist instance of LaSuite Meet. Click
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
| **SMTP Service** | Email notifications | - |
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
## Software Requirements
+119 -116
View File
@@ -14,7 +14,7 @@ This document is a step-by-step guide that describes how to install LaSuite Meet
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
@@ -311,119 +311,122 @@ frontend:
These are the environmental options available on meet backend.
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+6 -4
View File
@@ -24,9 +24,10 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=http://garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
@@ -63,7 +64,8 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_VERSION=2
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
+2
View File
@@ -0,0 +1,2 @@
# Filled with a random value by `make create-env-files`
GARAGE_RPC_SECRET=
+4 -3
View File
@@ -2,8 +2,9 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -1,6 +1,7 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
+4 -3
View File
@@ -2,11 +2,12 @@ APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_ENDPOINT_URL="garage:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
+35 -18
View File
@@ -6,9 +6,11 @@ import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
import boto3
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
@@ -28,8 +30,6 @@ from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
@@ -59,6 +59,30 @@ server = AgentServer(
server.setup_fnc = prewarm
def create_s3_client():
"""Create an S3 client for the configured endpoint and region.
The endpoint may be given with or without a scheme: the scheme always
follows AWS_S3_SECURE_ACCESS.
"""
endpoint = (
os.getenv("AWS_S3_ENDPOINT_URL", "")
.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
secure = os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true"
return boto3.client(
"s3",
endpoint_url=f"{'https' if secure else 'http'}://{endpoint}",
aws_access_key_id=os.getenv("AWS_S3_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
region_name=os.getenv("AWS_S3_REGION_NAME"),
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
@@ -121,18 +145,13 @@ class MetadataCollector:
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.s3_client = create_s3_client()
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
@@ -201,20 +220,18 @@ class MetadataCollector:
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=self.output_filename,
Body=data,
ContentType="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
except (BotoCoreError, ClientError):
logger.exception(
"Failed to upload meeting metadata",
)
+8 -8
View File
@@ -4,21 +4,21 @@ name = "agents"
version = "1.32.1"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.7.0",
"livekit-plugins-deepgram==1.7.0",
"livekit-plugins-silero==1.7.0",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"boto3==1.43.56",
"python-dotenv==1.2.3",
"protobuf==7.36.0",
"sentry-sdk==2.68.1",
"websockets==17.1",
"httpx==0.28.1",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.16.4",
]
[tool.uv]
+858 -853
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -279,9 +279,16 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
list_display = [
"name",
"slug",
"access_level",
"get_owner",
"created_at",
"last_started_at",
]
list_filter = ["access_level", "created_at", "last_started_at"]
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -476,11 +483,6 @@ class ApplicationAdminForm(forms.ModelForm):
if self.instance.pk and self.instance.scopes:
self.fields["scopes"].initial = self.instance.scopes
# On creation: display generated credentials without allowing edits
for name in ("client_id", "client_secret"):
if name in self.fields:
self.fields[name].widget.attrs["readonly"] = True
@admin.register(models.Application)
class ApplicationAdmin(admin.ModelAdmin):
+27
View File
@@ -20,6 +20,33 @@ class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RoomCreationUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle room creation per authenticated user.
Can be declared at the viewset level: every action other than "create"
is left unthrottled, so the same class can be reused on any viewset
exposing a room creation endpoint.
"""
scope = "room_creation"
def get_cache_key(self, request, view):
"""Throttle only room creations."""
if getattr(view, "action", None) != "create":
return None
return super().get_cache_key(request, view)
class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
"""Cap room creation per authenticated user over a day.
Complements the short-term RoomCreationUserRateThrottle, which absorbs
bursts but lets a user steadily create rooms over hours or days.
"""
scope = "room_creation_daily"
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
+4
View File
@@ -180,6 +180,10 @@ class RoomViewSet(
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
throttle_classes = [
throttling.RoomCreationUserRateThrottle,
throttling.RoomCreationDailyUserRateThrottle,
]
def get_object(self):
"""Allow getting a room by its slug."""
+2 -1
View File
@@ -4,6 +4,7 @@ import copy
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -73,7 +74,7 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.check_client_secret(client_secret):
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
-10
View File
@@ -7,8 +7,6 @@ from logging import getLogger
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
from .hashers import CLIENT_SECRET_HASH_PATTERN
logger = getLogger(__name__)
@@ -26,14 +24,6 @@ class SecretField(models.CharField):
secret = getattr(model_instance, self.attname)
if CLIENT_SECRET_HASH_PATTERN.fullmatch(secret):
logger.debug(
"%s: %s is already hashed with sha256.",
model_instance,
self.attname,
)
return secret
try:
hasher = identify_hasher(secret)
logger.debug(
-46
View File
@@ -1,46 +0,0 @@
"""Application secrets only: keep fast hashing out of PASSWORD_HASHERS.
Secrets must be securely randomly generated, not human-chosen.
"""
import hashlib
import re
from django.contrib.auth.hashers import check_password
from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes
CLIENT_SECRET_HASH_ALGORITHM = "sha256"
CLIENT_SECRET_HASH_VERSION = "v0"
CLIENT_SECRET_HASH_PREFIX = ( # noqa: S105 - format identifier, not a secret
f"{CLIENT_SECRET_HASH_ALGORITHM}${CLIENT_SECRET_HASH_VERSION}$"
)
# Accept only the versioned format: sha256$v0$<digest>.
CLIENT_SECRET_HASH_PATTERN = re.compile(
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P<digest>[0-9a-f]{{64}})"
)
def _digest(raw_secret):
"""Return the hex SHA-256 digest of a raw secret."""
return hashlib.sha256(force_bytes(raw_secret)).hexdigest()
def hash_client_secret(raw_secret):
"""Hash a machine-generated application secret without key stretching."""
return f"{CLIENT_SECRET_HASH_PREFIX}{_digest(raw_secret)}"
def verify_client_secret(raw_secret, encoded):
"""Verify the versioned application format or a legacy Django password hash."""
if raw_secret is None:
return False
match = CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)
# Legacy path
if not match:
return check_password(raw_secret, encoded)
return constant_time_compare(match["digest"], _digest(raw_secret))
@@ -0,0 +1,94 @@
"""Purge inactive rooms."""
from datetime import timedelta
from itertools import batched
from logging import getLogger
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from core.models import Recording, RecordingStatusChoices, Room
logger = getLogger(__name__)
CHUNK_SIZE = 500
class Command(BaseCommand):
"""
Delete rooms that have not been started for ROOM_INACTIVITY_DELETION_DAYS days:
- rooms which were last started before that period
- rooms never started and created before that period
Rooms holding a saved recording that has not expired are kept.
"""
help = "Purge inactive rooms"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="List the rooms that would be purged without deleting them",
)
def handle(self, *args, **options):
"""Browse inactive rooms and delete them chunk by chunk."""
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
self.stdout.write(
"Purging inactive rooms is disabled "
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
)
return
now = timezone.now()
inactive_rooms = self.get_inactive_rooms(now)
inactive_count = inactive_rooms.count()
if not inactive_count:
self.stdout.write("No inactive room to purge.")
return
if options["dry_run"]:
self.stdout.write(
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
)
names = inactive_rooms.values_list("name", flat=True)
for name in names.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}")
return
purged_count = 0
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
for room_id, slug in chunk:
logger.info("Purging inactive room %s (%s)", room_id, slug)
_, deleted_by_model = inactive_rooms.filter(
pk__in=[room_id for room_id, _ in chunk]
).delete()
purged_count += deleted_by_model.get("core.Room", 0)
self.stdout.write(f"Purged {purged_count} inactive room(s).")
@staticmethod
def get_inactive_rooms(now):
"""Return the rooms inactive for too long that no recording protects."""
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
is_inactive = Q(last_started_at__lt=threshold) | Q(
last_started_at__isnull=True, created_at__lt=threshold
)
protected_recordings = Recording.objects.filter(
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
)
if settings.RECORDING_EXPIRATION_DAYS:
protected_recordings = protected_recordings.filter(
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
)
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
@@ -1,19 +0,0 @@
"""Add a separate fast hash while preserving legacy credentials for rollback."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0022_user_default_room_access_level_and_more"),
]
operations = [
migrations.AddField(
model_name="application",
name="client_secret_sha256",
field=models.CharField(
max_length=255, null=True, blank=True
),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0023_alter_recording_status'),
]
operations = [
migrations.AddField(
model_name='room',
name='last_started_at',
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, help_text='date and time at which the room was last started', null=True, verbose_name='last started at'),
preserve_default=False,
),
]
+20 -68
View File
@@ -14,7 +14,6 @@ from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.hashers import identify_hasher
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
@@ -26,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, hashers, utils
from . import fields, utils
from .recording.enums import FileExtension
from .validators import sub_validator
@@ -88,6 +87,17 @@ class RecordingStatusChoices(models.TextChoices):
cls.FAILED_TO_STOP,
}
@classmethod
def saved_statuses(cls):
"""Return the statuses of a recording whose file users can access."""
return {
cls.NOTIFICATION_SUCCEEDED,
cls.SAVED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
}
class RecordingModeChoices(models.TextChoices):
"""Recording mode choices."""
@@ -427,6 +437,13 @@ class Room(Resource):
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
last_started_at = models.DateTimeField(
verbose_name=_("last started at"),
help_text=_("date and time at which the room was last started"),
blank=True,
null=True,
editable=False,
)
class Meta:
db_table = "meet_room"
@@ -683,12 +700,7 @@ class Recording(BaseModel):
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
return self.status in RecordingStatusChoices.saved_statuses()
@property
def extension(self):
@@ -812,9 +824,6 @@ class Application(BaseModel):
default=utils.generate_client_secret,
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
)
client_secret_sha256 = models.CharField(
max_length=255, null=True, blank=True, editable=False
)
scopes = ArrayField(
models.CharField(max_length=50, choices=ApplicationScope.choices),
default=list,
@@ -830,63 +839,6 @@ class Application(BaseModel):
def __str__(self):
return f"{self.name!s}"
def save(self, *args, **kwargs):
"""Populate the fast hash on creation when the raw secret is available."""
if self._state.adding:
# Prevent hashing an existing hash instead of the original secret
try:
if not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(self.client_secret):
identify_hasher(self.client_secret)
except ValueError:
# SecretField.pre_save hashes the legacy field after this method
self.client_secret_sha256 = hashers.hash_client_secret(
self.client_secret
)
return super().save(*args, **kwargs)
def rotate_client_secret(self):
"""Persist a new generated secret and return its raw value to the caller.
This is the only supported rotation path while both credential fields coexist.
Direct writes may leave a stale fast hash that still accepts the revoked secret,
while saving a stale instance may restore previous credentials.
This transitional risk is accepted until the legacy field is removed
and rotation writes only the fast hash.
"""
secret = utils.generate_client_secret()
self.client_secret = secret
self.client_secret_sha256 = hashers.hash_client_secret(secret)
self.save(update_fields=["client_secret", "client_secret_sha256"])
return secret
def check_client_secret(self, raw_secret):
"""Verify the secret and lazily populate its fast hash for future logins."""
if self.client_secret_sha256 is not None:
return hashers.verify_client_secret(raw_secret, self.client_secret_sha256)
original_hash = self.client_secret
if not hashers.verify_client_secret(raw_secret, original_hash):
return False
encoded = hashers.hash_client_secret(raw_secret)
updated = Application.objects.filter(
pk=self.pk, client_secret=original_hash, client_secret_sha256__isnull=True
).update(client_secret_sha256=encoded)
if updated:
self.client_secret_sha256 = encoded
return True
try:
self.refresh_from_db()
except Application.DoesNotExist:
return False
current_hash = self.client_secret_sha256 or self.client_secret
return hashers.verify_client_secret(raw_secret, current_hash)
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
+13 -4
View File
@@ -8,6 +8,7 @@ from enum import Enum
from logging import getLogger
from django.conf import settings
from django.utils import timezone
from livekit import api
@@ -270,12 +271,20 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
room_updated_count = models.Room.objects.filter(pk=room_id).update(
last_started_at=timezone.now()
)
if not room_updated_count:
raise ActionFailedError(f"Room with ID {room_id} does not exist")
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
room = models.Room.objects.get(pk=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(
f"Room with ID {room_id} does not exist"
) from err
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
@@ -0,0 +1,239 @@
"""Tests for the purge_inactive_rooms management command."""
import logging
from datetime import timedelta
from io import StringIO
from unittest import mock
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
BEFORE_PERIOD = timedelta(days=366)
WITHIN_PERIOD = timedelta(days=364)
@pytest.fixture(name="purge_enabled", autouse=True)
def fixture_purge_enabled(settings):
"""Enable the purge of the rooms inactive for a year."""
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
settings.RECORDING_EXPIRATION_DAYS = 30
def create_at(date, factory, **kwargs):
"""Build an object with the factory as if it was created at the given date."""
with mock.patch("django.utils.timezone.now", return_value=date):
return factory(**kwargs)
def call_purge(*args):
"""Run the purge command and return what it wrote on stdout."""
out = StringIO()
call_command("purge_inactive_rooms", *args, stdout=out)
return out.getvalue()
def room_exists(room):
"""Tell whether the room is still in database."""
return models.Room.objects.filter(pk=room.pk).exists()
def test_purge_inactive_rooms_disabled(settings):
"""Should delete nothing when no inactivity period is configured."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
assert "disabled" in call_purge()
assert room_exists(room)
def test_purge_inactive_rooms_without_recording_expiration(settings):
"""Should purge when recordings never expire, keeping rooms with a saved one."""
settings.RECORDING_EXPIRATION_DAYS = None
long_ago = timezone.now() - BEFORE_PERIOD
room = create_at(long_ago, factories.RoomFactory)
room_with_recording = create_at(long_ago, factories.RoomFactory)
create_at(
long_ago,
factories.RecordingFactory,
room=room_with_recording,
status=models.RecordingStatusChoices.SAVED,
)
assert call_purge() == "Purged 1 inactive room(s).\n"
assert not room_exists(room)
assert room_exists(room_with_recording)
def test_purge_inactive_rooms_without_recording_expiration_not_saved(settings):
"""Should delete a room whose recordings were never saved when none expire."""
settings.RECORDING_EXPIRATION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
call_purge()
assert not room_exists(room)
def test_purge_inactive_rooms_started_before_period(caplog):
"""Should delete a room that was last started before the inactivity period."""
now = timezone.now()
room = create_at(
now - timedelta(days=800),
factories.RoomFactory,
last_started_at=now - BEFORE_PERIOD,
)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 inactive room(s).\n"
assert not room_exists(room)
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
def test_purge_inactive_rooms_never_started_created_before_period():
"""Should delete a room that was never started and created before the period."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
call_purge()
assert not room_exists(room)
def test_purge_inactive_rooms_started_within_period():
"""Should keep a room created long ago that was started within the period."""
now = timezone.now()
room = create_at(
now - timedelta(days=800),
factories.RoomFactory,
last_started_at=now - WITHIN_PERIOD,
)
assert call_purge() == "No inactive room to purge.\n"
assert room_exists(room)
def test_purge_inactive_rooms_never_started_created_within_period():
"""Should keep a room that was never started but created within the period."""
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
assert call_purge() == "No inactive room to purge.\n"
assert room_exists(room)
@pytest.mark.parametrize(
"status", sorted(models.RecordingStatusChoices.saved_statuses())
)
def test_purge_inactive_rooms_recording_not_expired(settings, status):
"""Should keep a room holding a saved recording that has not expired yet."""
settings.RECORDING_EXPIRATION_DAYS = 400
long_ago = timezone.now() - BEFORE_PERIOD
room = create_at(long_ago, factories.RoomFactory)
create_at(long_ago, factories.RecordingFactory, room=room, status=status)
call_purge()
assert room_exists(room)
def test_purge_inactive_rooms_recording_expired(settings):
"""Should delete a room along with its recordings when they all have expired."""
settings.RECORDING_EXPIRATION_DAYS = 30
long_ago = timezone.now() - BEFORE_PERIOD
room = create_at(long_ago, factories.RoomFactory)
recording = create_at(
long_ago,
factories.RecordingFactory,
room=room,
status=models.RecordingStatusChoices.SAVED,
)
call_purge()
assert not room_exists(room)
assert not models.Recording.objects.filter(pk=recording.pk).exists()
@pytest.mark.parametrize(
"status",
[
status
for status in models.RecordingStatusChoices
if status not in models.RecordingStatusChoices.saved_statuses()
],
)
def test_purge_inactive_rooms_recording_not_saved(status):
"""Should delete a room whose recordings were never saved, even unexpired."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
factories.RecordingFactory(room=room, status=status)
call_purge()
assert not room_exists(room)
def test_purge_inactive_rooms_recording_saved_among_others():
"""Should keep a room holding a saved recording next to a failed one."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
call_purge()
assert room_exists(room)
def test_purge_inactive_rooms_deletes_accesses_and_resource():
"""Should delete the last owner access and the resource of a purged room."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
access = factories.UserResourceAccessFactory(
resource=room, role=models.RoleChoices.OWNER
)
call_purge()
assert not room_exists(room)
assert not models.Resource.objects.filter(pk=room.pk).exists()
assert not models.ResourceAccess.objects.filter(pk=access.pk).exists()
assert models.User.objects.filter(pk=access.user.pk).exists()
def test_purge_inactive_rooms_dry_run():
"""Should list the inactive rooms by name without deleting them on a dry run."""
long_ago = timezone.now() - BEFORE_PERIOD
rooms = [
create_at(long_ago, factories.RoomFactory, name=name)
for name in ("Alpha room", "Beta room")
]
factories.RoomFactory(name="Recent room")
assert call_purge("--dry-run") == (
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
)
assert all(room_exists(room) for room in rooms)
def test_purge_inactive_rooms_several_chunks():
"""Should delete every inactive room when they span several chunks."""
long_ago = timezone.now() - BEFORE_PERIOD
rooms = [create_at(long_ago, factories.RoomFactory) for _ in range(5)]
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
output = call_purge()
assert output == "Purged 5 inactive room(s).\n"
assert not any(room_exists(room) for room in rooms)
@@ -117,14 +117,14 @@ 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 ["garage:9000", "localhost:9000"]
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
assert query_params.pop("X-Amz-Credential") == [
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
f"meet-access-key/{now.strftime('%Y%m%d')}/local/s3/aws4_request"
]
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
assert query_params.pop("X-Amz-Expires") == ["60"]
@@ -9,6 +9,10 @@ from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...api.throttling import (
RoomCreationDailyUserRateThrottle,
RoomCreationUserRateThrottle,
)
from ...factories import RoomFactory, UserFactory
from ...models import Room, RoomAccessLevel
@@ -312,3 +316,145 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@pytest.fixture
def room_creation_throttle(monkeypatch):
"""Lower the room creation rate for the duration of a test."""
monkeypatch.setitem(
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"], "room_creation", "2/minute"
)
def test_api_rooms_create_throttled(room_creation_throttle):
"""Excess requests are rejected and create no room."""
client = APIClient()
client.force_login(UserFactory())
for index in range(2):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
assert 0 < int(response["Retry-After"]) <= 60
assert Room.objects.count() == 2
def test_api_rooms_create_throttle_per_user(room_creation_throttle):
"""Users sharing an IP have independent creation limits."""
client = APIClient()
client.force_login(UserFactory())
for index in range(2):
response = client.post("/api/v1.0/rooms/", {"name": f"First user room {index}"})
assert response.status_code == 201
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
client.force_login(UserFactory())
response = client.post("/api/v1.0/rooms/", {"name": "Second user room"})
assert response.status_code == 201
def test_api_rooms_create_throttle_does_not_limit_other_actions(room_creation_throttle):
"""Exhausting creation capacity leaves listing and updating available."""
client = APIClient()
client.force_login(UserFactory())
for index in range(2):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
room_id = response.json()["id"]
assert client.post("/api/v1.0/rooms/", {"name": "Blocked room"}).status_code == 429
assert client.get("/api/v1.0/rooms/").status_code == 200
assert (
client.patch(
f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed room"}
).status_code
== 200
)
@pytest.fixture
def daily_room_creation_throttle(monkeypatch):
"""Use a tiny daily cap, a loose burst limit and a controllable clock.
Rates are patched with monkeypatch.setitem so they are restored after the
test. Returns a one-item list holding the current fake timestamp.
"""
rates = RoomCreationDailyUserRateThrottle.THROTTLE_RATES
monkeypatch.setitem(rates, "room_creation", "100/minute")
monkeypatch.setitem(rates, "room_creation_daily", "3/day")
now = [1_000_000.0]
monkeypatch.setattr(RoomCreationUserRateThrottle, "timer", lambda self: now[0])
return now
def test_api_rooms_create_daily_throttled(daily_room_creation_throttle):
"""The daily cap still applies once the short-term window has elapsed."""
now = daily_room_creation_throttle
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
now[0] += 120 # Spread creations beyond the short-term window.
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
assert int(response["Retry-After"]) > 60
assert Room.objects.count() == 3
def test_api_rooms_create_daily_throttle_resets(daily_room_creation_throttle):
"""Room creation is allowed again once a day has passed."""
now = daily_room_creation_throttle
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
now[0] += 24 * 60 * 60 + 1
response = client.post("/api/v1.0/rooms/", {"name": "Next day room"})
assert response.status_code == 201
def test_api_rooms_create_daily_throttle_per_user(daily_room_creation_throttle):
"""Each user has its own daily cap."""
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
assert client.post("/api/v1.0/rooms/", {"name": "Blocked"}).status_code == 429
client.force_login(UserFactory())
response = client.post("/api/v1.0/rooms/", {"name": "Other user room"})
assert response.status_code == 201
def test_api_rooms_create_daily_throttle_does_not_limit_other_actions(
daily_room_creation_throttle,
):
"""Reaching the daily cap leaves listing and updating available."""
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
room_id = response.json()["id"]
assert client.post("/api/v1.0/rooms/", {"name": "Blocked"}).status_code == 429
assert client.get("/api/v1.0/rooms/").status_code == 200
response = client.patch(f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed"})
assert response.status_code == 200
@@ -7,6 +7,7 @@ from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -507,3 +508,20 @@ def test_api_rooms_retrieve_administrators(
role=str(user_access.role),
participant_id=None,
)
@pytest.mark.parametrize("access_level", RoomAccessLevel)
@pytest.mark.parametrize("role", [None, *RoleChoices])
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
"""Should not expose when the room was last started, whoever the requester is."""
room = RoomFactory(access_level=access_level, last_started_at=timezone.now())
client = APIClient()
user = UserFactory()
if role is not None:
UserResourceAccessFactory(resource=room, user=user, role=role)
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert "last_started_at" not in response.json()
@@ -3,8 +3,11 @@ Test rooms API endpoints in the Meet core app: update.
"""
import random
from datetime import timedelta
from unittest.mock import patch
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -225,6 +228,39 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("method", ["put", "patch"])
def test_api_rooms_update_last_started_at_ignored(method):
"""Should ignore a "last_started_at" value sent by a client.
The field is only ever written by the LiveKit "room_started" webhook: it is not
declared on the serializer and is "editable=False" on the model. A client must
not be able to keep a room alive by postponing its last start date.
"""
user = UserFactory()
last_started_at = timezone.now() - timedelta(days=30)
room = RoomFactory(
name="Old name",
last_started_at=last_started_at,
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
response = getattr(client, method)(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name", "last_started_at": timezone.now().isoformat()},
format="json",
)
assert response.status_code == 200
assert "last_started_at" not in response.json()
room.refresh_from_db()
# The rest of the payload was applied, so the request was not simply rejected
assert room.name == "New name"
assert room.last_started_at == last_started_at
@pytest.mark.parametrize(
"configuration",
[
@@ -5,12 +5,16 @@ Test LiveKitEvents service.
import logging
import uuid
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.models import Room
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
@@ -696,6 +700,82 @@ def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule.assert_not_called()
def test_handle_room_started_records_access(service, settings):
"""Should record the access on a room that is started for the first time."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
other_room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
now = timezone.now()
with mock.patch("django.utils.timezone.now", return_value=now):
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at == now
other_room.refresh_from_db()
assert other_room.last_started_at is None
def test_handle_room_started_overwrites_previous_access(service, settings):
"""Should overwrite the previous access each time the room is started again."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
now = timezone.now()
room = RoomFactory(last_started_at=now - timedelta(days=30))
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
with mock.patch("django.utils.timezone.now", return_value=now):
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at == now
def test_handle_room_started_only_updates_access(service, settings):
"""Should leave the slug and the update date untouched when recording the access."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
Room.objects.filter(pk=room.pk).update(slug="𓆑")
room.refresh_from_db()
updated_at = room.updated_at
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at is not None
assert room.slug == "𓆑"
assert room.updated_at == updated_at
@mock.patch.object(
SIPManagement,
"ensure_dispatch_rule",
side_effect=SIPException("Test error"),
)
def test_handle_room_started_records_access_when_dispatch_rule_creation_fails(
mock_ensure_dispatch_rule, service, settings
):
"""Should still record the access when ensuring the dispatch rule fails."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
with pytest.raises(ActionFailedError):
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at is not None
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
mock_data = mock.MagicMock()
@@ -1,350 +0,0 @@
"""Application hashing and migration of existing credentials."""
import hashlib
from unittest import mock
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils.crypto import get_random_string
import pytest
from rest_framework.test import APIClient
from core import hashers
from core.factories import ApplicationFactory, UserFactory
from core.models import Application
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("secret", ["short", "a" * 128, b"byte-secret"])
def test_application_hash(secret):
"""Application hashes verify correctly but are not accepted for user passwords."""
encoded = hashers.hash_client_secret(secret)
raw = secret.encode() if isinstance(secret, str) else secret
algorithm, version, digest = encoded.split("$")
assert algorithm == "sha256"
assert version == "v0"
assert digest == hashlib.sha256(raw).hexdigest()
assert hashers.hash_client_secret(secret) == encoded
assert hashers.verify_client_secret(secret, encoded)
assert not hashers.verify_client_secret("wrong", encoded)
assert not hashers.verify_client_secret(None, encoded)
assert not hashers.verify_client_secret(secret, "sha256$invalid")
assert not hashers.verify_client_secret(secret, "sha256$v1$" + digest)
assert not check_password(secret, encoded)
with pytest.raises(ValueError):
identify_hasher(encoded)
assert not make_password(raw.decode()).startswith("sha256$")
@pytest.mark.parametrize("algorithm", ["pbkdf2_sha256", "md5"])
def test_token_migrates_legacy_secret_once(algorithm):
"""The same client secret works before and after migration, with no later writes."""
secret = get_random_string(128)
user = UserFactory()
legacy = make_password(secret, hasher=algorithm)
app = ApplicationFactory(client_secret=legacy)
app.refresh_from_db()
assert app.client_secret == legacy
assert app.client_secret_sha256 is None
payload = {
"client_id": app.client_id,
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
}
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
app.refresh_from_db()
migrated = app.client_secret_sha256
assert check_password(secret, app.client_secret)
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated)["digest"]
assert hashers.verify_client_secret(secret, migrated)
with CaptureQueriesContext(connection) as queries:
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
app.refresh_from_db()
assert app.client_secret_sha256 == migrated
assert app.client_secret == legacy
def test_wrong_secret_does_not_migrate():
"""Failed authentication leaves a production PBKDF2 hash untouched."""
user = UserFactory()
legacy = make_password(get_random_string(128), hasher="pbkdf2_sha256")
app = ApplicationFactory(client_secret=legacy)
response = APIClient().post(
"/external-api/v1.0/application/token/",
{
"client_id": app.client_id,
"client_secret": "wrong",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
app.refresh_from_db()
assert app.client_secret == legacy
assert app.client_secret_sha256 is None
def test_migration_preserves_concurrent_rotation():
"""Migration must not restore a secret rotated after verification."""
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret, hasher="pbkdf2_sha256")
)
replacement = make_password(get_random_string(128), hasher="pbkdf2_sha256")
def verify_then_rotate(raw, encoded):
verified = check_password(raw, encoded)
Application.objects.filter(pk=app.pk).update(client_secret=replacement)
return verified
with mock.patch.object(hashers, "check_password", side_effect=verify_then_rotate):
assert app.check_client_secret(secret) is False
app.refresh_from_db()
assert app.client_secret == replacement
assert app.client_secret_sha256 is None
def test_migration_preserves_concurrent_migration():
"""Authentication succeeds when another request migrates the same secret."""
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret, hasher="pbkdf2_sha256")
)
migrated = hashers.hash_client_secret(secret)
def verify_then_migrate(raw, encoded):
verified = check_password(raw, encoded)
Application.objects.filter(pk=app.pk).update(client_secret_sha256=migrated)
return verified
with mock.patch.object(hashers, "check_password", side_effect=verify_then_migrate):
assert app.check_client_secret(secret) is True
app.refresh_from_db()
assert app.client_secret_sha256 == migrated
def test_migration_preserves_concurrent_deletion():
"""Authentication fails when the application is deleted after verification."""
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret, hasher="pbkdf2_sha256")
)
def verify_then_delete(raw, encoded):
verified = check_password(raw, encoded)
Application.objects.filter(pk=app.pk).delete()
return verified
with mock.patch.object(hashers, "check_password", side_effect=verify_then_delete):
assert app.check_client_secret(secret) is False
assert not Application.objects.filter(pk=app.pk).exists()
@pytest.mark.parametrize(
"secret",
[
"sha256$my-secret",
"sha256$" + "a" * 63,
"sha256$" + "a" * 64,
"sha256$" + "g" * 64,
"sha256$" + "a" * 64 + "\n",
"sha256$$" + "a" * 64,
"sha256$short$" + "a" * 64,
"sha256$" + "b" * 22 + "$" + "g" * 64,
"sha256$" + "b" * 22 + "$" + "a" * 64,
"sha256$v0$" + "g" * 64,
"sha256$v0$" + "a" * 63,
"sha256$v1$" + "a" * 64,
],
)
def test_prefixed_plaintext_is_hashed(secret):
"""A prefix alone must not cause a raw secret to bypass hashing."""
assert not hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(secret)
app = ApplicationFactory(client_secret=secret)
app.refresh_from_db()
encoded = app.client_secret_sha256
assert encoded != secret
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)["digest"]
assert app.check_client_secret(secret)
app.name = "Updated application"
app.save()
app.refresh_from_db()
assert app.client_secret_sha256 == encoded
def test_unsalted_secret_is_rejected():
"""Only salted SHA-256 hashes are accepted."""
secret = get_random_string(128)
encoded = f"sha256${hashlib.sha256(secret.encode()).hexdigest()}"
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded) is None
assert not hashers.verify_client_secret(secret, encoded)
def test_new_application_supports_legacy_verification(settings):
"""A rollback can authenticate applications created by the new release."""
settings.PASSWORD_HASHERS = [
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
secret = get_random_string(128)
app = ApplicationFactory(client_secret=secret)
app.refresh_from_db()
assert app.client_secret.startswith("pbkdf2_sha256$")
assert check_password(secret, app.client_secret)
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
with mock.patch.object(hashers, "check_password", side_effect=AssertionError):
assert app.check_client_secret(secret)
assert not app.check_client_secret("wrong")
def test_unrelated_save_preserves_both_hashes():
"""Saving an application's metadata does not change either credential hash."""
app = ApplicationFactory()
original = (app.client_secret, app.client_secret_sha256)
app.name = "Renamed"
app.save()
app.refresh_from_db()
assert (app.client_secret, app.client_secret_sha256) == original
def test_creation_with_legacy_hash_defers_fast_hash_until_login():
"""An imported Django hash is preserved, never treated as the raw secret."""
secret = get_random_string(128)
legacy = make_password(secret, hasher="pbkdf2_sha256")
app = ApplicationFactory(client_secret=legacy)
app.refresh_from_db()
assert app.client_secret == legacy
assert app.client_secret_sha256 is None
assert not app.check_client_secret(legacy)
assert app.check_client_secret(secret)
app.refresh_from_db()
assert app.client_secret == legacy
assert hashers.verify_client_secret(secret, app.client_secret_sha256)
def test_metadata_only_save_does_not_rotate_secret():
"""A secret excluded from update_fields must not change either stored hash."""
app = ApplicationFactory()
original = (app.client_secret, app.client_secret_sha256)
app.client_secret = get_random_string(128)
app.name = "Renamed"
app.save(update_fields=["name"])
app.refresh_from_db()
assert (app.client_secret, app.client_secret_sha256) == original
def test_empty_update_fields_does_not_rotate_secret():
"""Django's explicit no-op save must not update either credential field."""
app = ApplicationFactory()
original = (app.client_secret, app.client_secret_sha256)
app.client_secret = get_random_string(128)
with CaptureQueriesContext(connection) as queries:
app.save(update_fields=[])
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
app.refresh_from_db()
assert (app.client_secret, app.client_secret_sha256) == original
def test_creation_with_salted_hash_skips_fast_hash():
"""An existing salted hash must not be hashed again as plaintext."""
encoded = hashers.hash_client_secret(get_random_string(128))
app = ApplicationFactory(client_secret=encoded)
app.refresh_from_db()
assert app.client_secret == encoded
assert app.client_secret_sha256 is None
@pytest.mark.parametrize("legacy_only", [False, True])
def test_rotate_client_secret_updates_both_hashes(legacy_only, settings):
"""Rotation revokes the old secret for both current and rollback releases."""
settings.PASSWORD_HASHERS = [
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret) if legacy_only else secret
)
replacement = app.rotate_client_secret()
assert replacement != secret
assert len(replacement) == settings.APPLICATION_CLIENT_SECRET_LENGTH
assert app.check_client_secret(replacement)
assert not app.check_client_secret(secret)
app.refresh_from_db()
assert app.client_secret.startswith("pbkdf2_sha256$")
assert check_password(replacement, app.client_secret)
assert not check_password(secret, app.client_secret)
assert hashers.verify_client_secret(replacement, app.client_secret_sha256)
assert not app.check_client_secret(secret)
assert app.client_secret != replacement
assert app.client_secret_sha256 != replacement
def test_rotate_client_secret_preserves_metadata():
"""Rotation persists only the credential fields, not other pending changes."""
app = ApplicationFactory()
original_name = app.name
original_client_id = app.client_id
app.name = "Unsaved metadata"
app.rotate_client_secret()
app.refresh_from_db()
assert app.name == original_name
assert app.client_id == original_client_id
def test_rotate_client_secret_repeatedly_revokes_previous_secrets():
"""Only the latest generated secret remains valid after successive rotations."""
original = get_random_string(128)
app = ApplicationFactory(client_secret=original)
first = app.rotate_client_secret()
second = app.rotate_client_secret()
app.refresh_from_db()
assert len({original, first, second}) == 3
assert app.check_client_secret(second)
assert check_password(second, app.client_secret)
for revoked in (original, first):
assert not app.check_client_secret(revoked)
assert not check_password(revoked, app.client_secret)
def test_token_endpoint_rejects_rotated_secret():
"""New token requests reject the revoked secret and accept its replacement."""
secret = get_random_string(128)
app = ApplicationFactory(client_secret=secret)
user = UserFactory()
client = APIClient()
payload = {
"client_id": app.client_id,
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
}
endpoint = "/external-api/v1.0/application/token/"
assert client.post(endpoint, payload, format="json").status_code == 200
replacement = app.rotate_client_secret()
assert client.post(endpoint, payload, format="json").status_code == 401
payload["client_secret"] = replacement
assert client.post(endpoint, payload, format="json").status_code == 200
+63 -253
View File
@@ -7,20 +7,17 @@ Tests for external API /token endpoint
from unittest import mock
from urllib.parse import urlencode
from django.contrib.auth.hashers import check_password
import jwt
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import hashers
from core.factories import (
ApplicationDomainFactory,
ApplicationFactory,
UserFactory,
)
from core.models import Application, ApplicationScope, User
from core.models import ApplicationScope, User
from core.services import provisional_user_service
pytestmark = pytest.mark.django_db
@@ -31,13 +28,15 @@ def test_api_applications_generate_token_application_disabled(settings):
settings.APPLICATION_ENABLED = False
user = UserFactory(email="user@example.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -56,13 +55,16 @@ def test_api_applications_generate_token_application_disabled(settings):
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
UserFactory(email="User.Family@example.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
# Store plain secret before it's hashed
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -93,13 +95,15 @@ def test_api_applications_generate_token_form_urlencoded(settings):
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -166,8 +170,11 @@ def test_api_applications_generate_token_form_urlencoded_missing_fields():
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
@@ -191,13 +198,15 @@ def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
plain_secret = "s3cr3t&with=special+chars%42"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -270,54 +279,14 @@ def test_api_applications_generate_token_invalid_client_secret():
assert "Invalid credentials" in str(response.data)
def test_token_unknown_client_id_with_valid_secret():
"""A valid secret cannot authenticate an unknown client ID."""
secret = "application-a-secret"
ApplicationFactory(client_secret=secret)
user = UserFactory()
response = APIClient().post(
"/external-api/v1.0/application/token/",
{
"client_id": "unknown-client-id",
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_token_rejects_secret_owned_by_another_application():
"""Application A's secret cannot authenticate application B."""
secret_a = "application-a-secret"
ApplicationFactory(client_secret=secret_a)
application_b = ApplicationFactory(client_secret="application-b-secret")
user = UserFactory()
response = APIClient().post(
"/external-api/v1.0/application/token/",
{
"client_id": application_b.client_id,
"client_secret": secret_a,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False)
plain_secret = "test-secret-123"
application = ApplicationFactory(client_secret=plain_secret, is_active=False)
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
@@ -359,8 +328,11 @@ def test_api_applications_generate_token_inactive_application_wrong_secret():
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
@@ -381,10 +353,13 @@ def test_api_applications_generate_token_invalid_email_format():
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
application = ApplicationFactory(is_active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -404,14 +379,16 @@ def test_api_applications_generate_token_domain_not_authorized():
def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed."""
user = UserFactory(email="user@allowed.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -430,8 +407,11 @@ def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
@@ -454,13 +434,15 @@ def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
user = UserFactory(email="user@example.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -505,13 +487,15 @@ def test_api_applications_token_new_user(settings):
assert len(User.objects.all()) == 0
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -561,13 +545,15 @@ def test_api_applications_token_existing_user(settings):
assert len(User.objects.all()) == 1
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
@@ -612,10 +598,12 @@ def test_api_applications_token_new_user_race_condition(mock_get_by_email, setti
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
email = "john.doe@example.com"
@@ -665,10 +653,12 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
plain_secret = "test-secret-123"
application = ApplicationFactory(
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
@@ -684,183 +674,3 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
assert response.status_code == 409
assert mock_get_or_create.call_count == 1
def test_token_populates_fast_hash_and_stops_using_legacy_hash():
"""First login migrates; subsequent logins use only the fast hash."""
secret = "application-secret"
application = ApplicationFactory(client_secret=secret)
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
application.refresh_from_db()
original_hash = application.client_secret
user = UserFactory()
payload = {
"client_id": application.client_id,
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
}
client = APIClient()
with mock.patch.object(
hashers, "check_password", wraps=hashers.check_password
) as legacy_verifier:
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
legacy_verifier.assert_called_once_with(secret, original_hash)
application.refresh_from_db()
migrated_hash = application.client_secret_sha256
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated_hash)
assert hashers.verify_client_secret(secret, migrated_hash)
assert application.client_secret == original_hash
# Fail immediately if a subsequent login tries the legacy verifier.
with mock.patch.object(
hashers,
"check_password",
side_effect=AssertionError("Legacy hash must no longer be used"),
):
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
application.refresh_from_db()
assert application.client_secret_sha256 == migrated_hash
assert application.client_secret == original_hash
def test_token_failed_login_leaves_legacy_credentials_untouched():
"""An incorrect secret neither migrates nor changes the legacy hash."""
application = ApplicationFactory(client_secret="application-secret")
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
application.refresh_from_db()
original_hash = application.client_secret
user = UserFactory()
response = APIClient().post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
application.refresh_from_db()
assert application.client_secret == original_hash
assert application.client_secret_sha256 is None
def test_token_concurrent_successful_logins_preserve_first_migration():
"""Both logins succeed; the later migration preserves the first hash."""
secret = "application-secret"
application = ApplicationFactory(client_secret=secret)
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
application.refresh_from_db()
original_hash = application.client_secret
user = UserFactory()
payload = {
"client_id": application.client_id,
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
}
legacy_verifier = hashers.check_password
winning_hashes = []
def verify_then_complete_other_login(raw_secret, encoded):
verified = legacy_verifier(raw_secret, encoded)
# Complete another login before this request writes its migration.
# Restore the real verifier to avoid recursively invoking this callback.
with mock.patch.object(hashers, "check_password", new=legacy_verifier):
other_response = APIClient().post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert other_response.status_code == 200
application.refresh_from_db()
winning_hashes.append(application.client_secret_sha256)
return verified
with mock.patch.object(
hashers, "check_password", side_effect=verify_then_complete_other_login
) as verifier:
response = APIClient().post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
verifier.assert_called_once_with(secret, original_hash)
application.refresh_from_db()
assert application.client_secret_sha256 == winning_hashes[0]
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
assert application.client_secret == original_hash
def test_token_authenticates_after_rollback():
"""Legacy authentication still works after the fast hash is discarded."""
secret = "application-secret"
application = ApplicationFactory(client_secret=secret)
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
application.refresh_from_db()
original_hash = application.client_secret
user = UserFactory()
payload = {
"client_id": application.client_id,
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
}
client = APIClient()
# Authenticate with the new implementation and migrate the hash.
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
application.refresh_from_db()
assert hashers.verify_client_secret(secret, application.client_secret_sha256)
assert application.client_secret == original_hash
Application.objects.filter(pk=application.pk).update(client_secret_sha256=None)
def legacy_check(instance, raw_secret):
return check_password(raw_secret, instance.client_secret)
# Simulate the old release's verification using only the legacy field.
with mock.patch.object(
Application,
"check_client_secret",
autospec=True,
side_effect=legacy_check,
) as verifier:
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
verifier.assert_called_once()
application.refresh_from_db()
assert application.client_secret == original_hash
assert application.client_secret_sha256 is None
@@ -6,12 +6,12 @@ Unit tests for the Application and ApplicationDomain models
from unittest import mock
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
import pytest
from core.factories import ApplicationDomainFactory, ApplicationFactory
from core.hashers import verify_client_secret
from core.models import Application, ApplicationDomain, ApplicationScope
pytestmark = pytest.mark.django_db
@@ -98,8 +98,8 @@ def test_models_application_client_secret_hashed_on_save():
# Secret should be hashed, not plain
assert application.client_secret != plain_secret
# Should verify with the application credential policy
assert verify_client_secret(plain_secret, application.client_secret) is True
# Should verify with check_password
assert check_password(plain_secret, application.client_secret) is True
def test_models_application_client_secret_preserves_existing_hash():
@@ -92,6 +92,12 @@ def test_models_rooms_access_level_default():
assert room.access_level == RoomAccessLevel.PUBLIC
def test_models_rooms_last_started_at_default():
"""Should have no last access date until the room is started."""
room = Room.objects.create(name="room")
assert room.last_started_at is None
# Access rights methods
+32 -15
View File
@@ -361,6 +361,16 @@ class Base(Configuration):
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"room_creation": values.Value(
default="50/minute",
environ_name="ROOM_CREATION_THROTTLE_RATES",
environ_prefix=None,
),
"room_creation_daily": values.Value(
default="1000/day",
environ_name="ROOM_CREATION_DAILY_THROTTLE_RATES",
environ_prefix=None,
),
"request_entry": values.Value(
default="150/minute",
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
@@ -730,6 +740,9 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
ROOM_INACTIVITY_DELETION_DAYS = values.PositiveIntegerValue(
None, environ_name="ROOM_INACTIVITY_DELETION_DAYS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
@@ -988,7 +1001,7 @@ class Base(Configuration):
environ_prefix=None,
)
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
50,
128,
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
environ_prefix=None,
)
@@ -1249,19 +1262,24 @@ class Base(Configuration):
stacklevel=2,
)
# Secrets use a 62-character alphanumeric charset (~5.95 bits/char).
# 43 characters provide at least 256 bits of entropy; 42 provide ~250 bits.
if cls.APPLICATION_CLIENT_SECRET_LENGTH < 43:
warnings.warn(
f"APPLICATION_CLIENT_SECRET_LENGTH={cls.APPLICATION_CLIENT_SECRET_LENGTH} "
"is below the recommended 43 characters (256 bits of entropy). "
"Application secrets use a fast hash and rely on high entropy to "
"resist offline guessing if the database leaks. "
"Please set APPLICATION_CLIENT_SECRET_LENGTH to at least 43.",
# We use UserWarning to make sure it shows up in production deployment
UserWarning,
stacklevel=2,
)
if cls.ROOM_INACTIVITY_DELETION_DAYS:
if not cls.RECORDING_EXPIRATION_DAYS:
warnings.warn(
"ROOM_INACTIVITY_DELETION_DAYS is set but "
"RECORDING_EXPIRATION_DAYS is not. Recordings never expire, so "
"inactive rooms holding a saved recording will never be purged.",
UserWarning,
stacklevel=2,
)
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
warnings.warn(
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
"recording will be kept past the inactivity period, until their "
"recordings expire.",
UserWarning,
stacklevel=2,
)
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
@@ -1348,7 +1366,6 @@ class Test(Base):
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
+14 -14
View File
@@ -2,7 +2,7 @@
# Meet package
#
[build-system]
requires = ["uv_build>=0.11.16,<0.12.0"]
requires = ["uv_build>=0.12.6,<0.13.0"]
build-backend = "uv_build"
[project]
@@ -24,7 +24,7 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.56",
"boto3==1.43.80",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
@@ -33,7 +33,7 @@ dependencies = [
"django-cors-headers==4.9.0",
"django-countries==9.0.0",
"django-filter==26.1",
"django-lasuite[all]==0.0.27",
"django-lasuite[all]==0.0.29",
"django-parler==2.4",
"redis==5.2.1",
"django-redis==7.0.0",
@@ -41,30 +41,30 @@ dependencies = [
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.16",
"djangorestframework==3.17.2",
"djangorestframework==3.18.0",
"drf_spectacular==0.30.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==26.0.0",
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"markdown==3.10.3",
"nested-multipart-parser==1.6.0",
"posthog==7.29.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.13.0",
"python-frontmatter==1.3.0",
"python-magic==0.4.27",
"requests==2.34.2",
"sentry-sdk==2.66.1",
"sentry-sdk==2.68.1",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.2.0",
"aiohttp==3.14.3",
"urllib3==2.7.0",
"phonenumbers==9.0.34",
"cryptography==50.0.0", # CVE-2026-69247
"phonenumbers==9.0.37",
"cryptography==50.0.1", # CVE-2026-69247
]
[project.urls]
@@ -76,20 +76,20 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.7.1",
"drf-spectacular-sidecar==2026.8.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.15.0",
"ipython==9.16.1",
"pyfakefs==6.2.0",
"pylint-django==2.8.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest-django==4.14.0",
"pytest==9.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.2",
"ruff==0.16.0",
"ruff==0.16.4",
"types-requests==2.33.0.20260712",
]
+749 -532
View File
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,7 @@ export const useAnalytics = ({
api_host: host,
flags_api_host: flags_api_host,
person_profiles: 'always',
remote_config_refresh_interval_ms: 0,
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: {
+16 -14
View File
@@ -24,10 +24,11 @@ _summaryEnvVars: &summaryEnvVars
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000/
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_SECURE_ACCESS: False
AWS_S3_REGION_NAME: local
AUTHORIZED_TENANTS: >
[
{
@@ -161,9 +162,9 @@ backend:
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
FRONTEND_IS_SILENT_LOGIN_ENABLED: False
# S3 Storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_STORAGE_BUCKET_NAME: meet-media-storage
# Telephony
ROOM_TELEPHONY_ENABLED: True
@@ -180,7 +181,7 @@ backend:
# Custom Background
AWS_S3_REGION_NAME: local
AWS_S3_SIGNATURE_VERSION: s3v4
AWS_S3_DOMAIN_REPLACE: https://minio.127.0.0.1.nip.io
AWS_S3_DOMAIN_REPLACE: https://garage.127.0.0.1.nip.io
MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io
FILE_UPLOAD_ENABLED: True
CELERY_ENABLED: True
@@ -264,11 +265,11 @@ ingressMedia:
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
host: garage.meet.svc.cluster.local
port: 9000
# ---- Extra ingress/service for background file uploads ------------
@@ -280,11 +281,11 @@ ingressMediaFiles:
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/files/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/files/$1
serviceMediaFiles:
host: minio.meet.svc.cluster.local
host: garage.meet.svc.cluster.local
port: 9000
# ---- STT Orchestration Microservice Components --------------------
@@ -362,10 +363,11 @@ agentMetadata:
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ENDPOINT_URL: garage.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_SECURE_ACCESS: False
AWS_S3_REGION_NAME: local
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_OUTPUT_FOLDER: metadata
@@ -16,11 +16,11 @@ egress:
address: redis-master:6379
password: pass
s3:
access_key: meet
secret: password
access_key: meet-access-key
secret: meet-secret-access-key
region: local
bucket: meet-media-storage
endpoint: http://minio:9000
endpoint: http://garage:9000
force_path_style: true
loadBalancer:
@@ -19,11 +19,11 @@ egress:
address: redis-master:6379
password: pass
s3:
access_key: meet
secret: password
access_key: meet-access-key
secret: meet-secret-access-key
region: local
bucket: meet-media-storage
endpoint: http://minio:9000
endpoint: http://garage:9000
force_path_style: true
loadBalancer:
+172
View File
@@ -0,0 +1,172 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: garage
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
spec:
rules:
- host: "garage.127.0.0.1.nip.io"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: garage
port:
number: 9000
tls:
- hosts:
- garage.127.0.0.1.nip.io
secretName: meet-tls
---
apiVersion: v1
kind: Service
metadata:
name: garage
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: client
port: 9000
protocol: TCP
targetPort: 9000
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: garage
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: garage-config
namespace: {{ .Release.Namespace | quote }}
data:
garage.toml: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
api_bind_addr = "[::]:9000"
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
s3_region = "local"
---
apiVersion: v1
kind: Secret
metadata:
name: garage-dev
namespace: {{ .Release.Namespace | quote }}
type: Opaque
data:
GARAGE_RPC_SECRET: {{ printf "%s/garage-rpc-secret" .Release.Namespace | sha256sum | b64enc }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: garage
namespace: {{ .Release.Namespace | quote }}
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: garage
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: garage
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: garage
spec:
containers:
- name: garage
command:
- /garage
- server
- --single-node
- --default-bucket
env:
- name: GARAGE_RPC_SECRET
valueFrom:
secretKeyRef:
name: garage-dev
key: GARAGE_RPC_SECRET
- name: GARAGE_DEFAULT_ACCESS_KEY
value: meet-access-key
- name: GARAGE_DEFAULT_SECRET_KEY
value: meet-secret-access-key
- name: GARAGE_DEFAULT_BUCKET
value: meet-media-storage
image: "dxflrs/garage:v2.4.1"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9000
name: client
readinessProbe:
exec:
command:
- /garage
- health
volumeMounts:
- mountPath: /etc/garage.toml
name: config
subPath: garage.toml
- mountPath: /var/lib/garage
name: data
volumes:
- name: config
configMap:
name: garage-config
- name: data
emptyDir:
---
# Garage denies cross-origin requests by default: allow the frontend to upload
# files straight to the bucket
apiVersion: batch/v1
kind: Job
metadata:
name: garage-cors
spec:
template:
spec:
containers:
- name: aws-cli
image: amazon/aws-cli:2.37.1
env:
- name: AWS_ACCESS_KEY_ID
value: meet-access-key
- name: AWS_SECRET_ACCESS_KEY
value: meet-secret-access-key
- name: AWS_DEFAULT_REGION
value: local
- name: AWS_ENDPOINT_URL
value: http://garage:9000
- name: BUCKET
value: meet-media-storage
command:
- /bin/sh
- -c
- |
deadline=$(($(date +%s) + 300))
until aws s3api head-bucket --bucket="$BUCKET" --cli-connect-timeout=5; do
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "Bucket $BUCKET still unavailable on $AWS_ENDPOINT_URL" >&2
exit 1
fi
echo "Waiting for bucket $BUCKET on $AWS_ENDPOINT_URL"
sleep 5
done
exec aws s3api put-bucket-cors --bucket="$BUCKET" \
--cors-configuration='{"CORSRules": [{"AllowedOrigins": ["https://meet.127.0.0.1.nip.io"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
restartPolicy: Never
backoffLimit: 3
-115
View File
@@ -1,115 +0,0 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
spec:
rules:
- host: "minio.127.0.0.1.nip.io"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio
port:
number: 9000
tls:
- hosts:
- minio.127.0.0.1.nip.io
secretName: meet-tls
---
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: client
port: 9000
protocol: TCP
targetPort: 9000
- name: console
port: 9001
protocol: TCP
targetPort: 9001
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: {{ .Release.Namespace | quote }}
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
spec:
containers:
- name: minio
command:
- /bin/sh
- -c
- |
minio server --console-address :9001 /data
env:
- name: MINIO_ROOT_USER
value: meet
- name: MINIO_ROOT_PASSWORD
value: password
image: "quay.io/minio/minio"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9000
name: client
- containerPort: 9001
name: console
volumeMounts:
- mountPath: /data
name: data
- mountPath: /etc/ssl/certs/mkcert-ca.pem
name: mkcert
subPath: rootCA.pem
volumes:
- name: data
emptyDir:
- name: mkcert
secret:
secretName: mkcert
---
apiVersion: batch/v1
kind: Job
metadata:
name: minio-bucket
spec:
template:
spec:
containers:
- name: mc
image: quay.io/minio/mc
command:
- /bin/sh
- -c
- |
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0
restartPolicy: Never
backoffLimit: 3
+2 -2
View File
@@ -45,10 +45,10 @@
| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `garage.meet.svc.cluster.local:9000` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;
` |
| `serviceMedia.host` | | `minio.meet.svc.cluster.local` |
| `serviceMedia.host` | | `garage.meet.svc.cluster.local` |
| `serviceMedia.port` | | `9000` |
| `serviceMedia.annotations` | | `{}` |
+13 -4
View File
@@ -128,7 +128,7 @@ ingressMedia:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
@@ -136,7 +136,7 @@ ingressMedia:
## @param serviceMedia.port
## @param serviceMedia.annotations
serviceMedia:
host: minio.meet.svc.cluster.local
host: garage.meet.svc.cluster.local
port: 9000
annotations: {}
@@ -171,7 +171,7 @@ ingressMediaFiles:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/files/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
add_header Content-Disposition "attachment";
@@ -180,7 +180,7 @@ ingressMediaFiles:
## @param serviceMediaFiles.port
## @param serviceMediaFiles.annotations
serviceMediaFiles:
host: minio.meet.svc.cluster.local
host: garage.meet.svc.cluster.local
port: 9000
annotations: {}
@@ -289,6 +289,9 @@ backend:
## @param backend.cronjobs[1].name Name of the CronJob
## @param backend.cronjobs[1].schedule Schedule in cron format
## @param backend.cronjobs[1].command The bash command to execute in the CronJob
## @param backend.cronjobs[2].name Name of the CronJob
## @param backend.cronjobs[2].schedule Schedule in cron format
## @param backend.cronjobs[2].command The bash command to execute in the CronJob
cronjobs:
- name: clean-pending-files
@@ -303,6 +306,12 @@ backend:
- "/bin/sh"
- "-c"
- "python manage.py purge_deleted_files"
- name: purge-inactive-rooms
schedule: "0 1 * * *"
command:
- "/bin/sh"
- "-c"
- "python manage.py purge_inactive_rooms"
## @param backend.mergeDuplicateUsers.command backend merge_duplicate_users command
## @param backend.mergeDuplicateUsers.restartPolicy backend merge_duplicate_users job restart policy
+5 -5
View File
@@ -10,18 +10,18 @@ dependencies = [
"pydantic-settings>=2.1.0",
"celery==5.6.3",
"redis==5.2.1",
"boto3==1.43.56",
"dockerflow==2026.3.4",
"minio==7.2.20",
"openai==2.48.0",
"posthog==7.29.0",
"openai==3.3.1",
"posthog==7.44.0",
"requests==2.34.2",
"sentry-sdk[fastapi, celery]==2.66.1",
"sentry-sdk[fastapi, celery]==2.68.1",
"langfuse==4.14.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.16.4",
"pytest==9.1.1",
"responses>=0.25.8",
]
+1 -1
View File
@@ -77,7 +77,7 @@ class Settings(BaseSettings):
summarize_queue_v2: str = "summarize-queue-v2"
call_webhook_queue_v2: str = "call-webhook-queue-v2"
# Minio settings
# S3 settings
aws_storage_bucket_name: str
aws_s3_endpoint_url: str
aws_s3_access_key_id: str
+48 -35
View File
@@ -1,6 +1,5 @@
"""File service to encapsulate files' manipulations."""
import io
import json
import logging
import os
@@ -9,11 +8,13 @@ import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import timedelta
from functools import cached_property
from pathlib import Path
from urllib.parse import urlparse
import boto3
import requests
from minio import Minio
from botocore.config import Config
from summary.core.config import get_settings
from summary.core.shared_models import WhisperXResponse
@@ -266,30 +267,44 @@ class FileServiceException(Exception):
pass
def _build_s3_client():
"""Build an S3 client for the configured endpoint and region.
The endpoint may be given with or without a scheme: the scheme always
follows `aws_s3_secure_access`.
"""
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
scheme = "https" if settings.aws_s3_secure_access else "http"
return boto3.client(
"s3",
endpoint_url=f"{scheme}://{endpoint}",
aws_access_key_id=settings.aws_s3_access_key_id,
aws_secret_access_key=settings.aws_s3_secret_access_key.get_secret_value(),
region_name=settings.aws_s3_region_name,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
class FileService:
"""Service for downloading and preparing files from MinIO storage."""
"""Service for downloading and preparing files from S3 storage."""
def __init__(self):
"""Initialize FileService with MinIO client and configuration."""
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
self._minio_client = Minio(
endpoint,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,
region=settings.aws_s3_region_name,
)
"""Initialize FileService with its configuration."""
self._bucket_name = settings.aws_storage_bucket_name
self._stream_chunk_size = 32 * 1024
self._max_duration_seconds = settings.recording_max_duration
@cached_property
def _s3_client(self):
"""S3 client, created on first use."""
return _build_s3_client()
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
"""Download file from a cloud storage URL to local temporary file."""
logger.info(
@@ -368,7 +383,7 @@ class FileService:
):
"""Download and prepare audio file for processing.
Downloads file from MinIO or an external cloud URL, validates duration,
Downloads file from S3 or an external cloud URL, validates duration,
and yields an open file handle with metadata. Automatically cleans up
temporary files when the context exits.
"""
@@ -416,16 +431,13 @@ class FileService:
logger.warning("Failed to remove temporary file %s: %s", path, e)
def store_transcript(self, *, transcript: WhisperXResponse, job_id: str) -> None:
"""Store transcript in MinIO."""
"""Store transcript in S3."""
logger.info("Storing transcript for job id %s", job_id)
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
logger.debug("Transcript path: %s", transcript_path)
data = transcript.model_dump_json().encode()
self._minio_client.put_object(
self._bucket_name,
transcript_path,
io.BytesIO(data),
length=len(data),
self._s3_client.put_object(
Bucket=self._bucket_name, Key=transcript_path, Body=data
)
logger.info("Transcript stored successfully for job id %s", job_id)
@@ -433,21 +445,20 @@ class FileService:
"""Get signed URL for transcript file."""
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
logger.debug("Transcript path: %s", transcript_path)
return self._minio_client.presigned_get_object(
self._bucket_name, transcript_path, expires=timedelta(hours=24)
return self._s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": self._bucket_name, "Key": transcript_path},
ExpiresIn=int(timedelta(hours=24).total_seconds()),
)
def store_summary(self, *, summary: str, job_id: str) -> None:
"""Store summary in MinIO."""
"""Store summary in S3."""
logger.info("Storing summary for job id %s", job_id)
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
logger.debug("Summary path: %s", summary_path)
data = summary.encode()
self._minio_client.put_object(
self._bucket_name,
summary_path,
io.BytesIO(data),
length=len(data),
self._s3_client.put_object(
Bucket=self._bucket_name, Key=summary_path, Body=data
)
logger.info("Summary stored successfully for job id %s", job_id)
@@ -455,6 +466,8 @@ class FileService:
"""Get signed URL for summary file."""
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
logger.debug("Summary path: %s", summary_path)
return self._minio_client.presigned_get_object(
self._bucket_name, summary_path, expires=timedelta(hours=24)
return self._s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": self._bucket_name, "Key": summary_path},
ExpiresIn=int(timedelta(hours=24).total_seconds()),
)
+134
View File
@@ -1,17 +1,22 @@
"""Unit tests for the file service."""
import json
from collections.abc import Callable, Iterator
from pathlib import Path
from unittest.mock import Mock
from urllib.parse import parse_qs, urlparse
import pytest
from botocore.stub import Stubber
from summary.core import file_service
from summary.core.file_service import (
FileService,
MediaInfo,
extract_audio_from_media,
get_media_info,
)
from summary.core.shared_models import WhisperXResponse
BASE_PATH = Path(__file__).parent.parent / "assets"
@@ -24,6 +29,46 @@ MEDIA_INFO_SAMPLE_VISIO = MediaInfo(
)
S3Settings = Callable[..., None]
@pytest.fixture
def s3_settings(monkeypatch: pytest.MonkeyPatch) -> S3Settings:
"""Configure the S3 settings read by the file service.
The returned function overrides some of them on top of the current ones.
The (frozen) settings are replaced by a copy, restored after the test.
"""
def override(**values) -> None:
monkeypatch.setattr(
file_service, "settings", file_service.settings.model_copy(update=values)
)
override(
aws_s3_endpoint_url="garage:9000",
aws_s3_secure_access=False,
aws_s3_region_name="fr-par",
aws_storage_bucket_name="meet-media-storage",
)
return override
@pytest.fixture
def s3_stubber(
monkeypatch: pytest.MonkeyPatch, s3_settings: S3Settings
) -> Iterator[Stubber]:
"""Stub the S3 client built by the file service.
An unexpected S3 call fails the test instead of reaching the network.
"""
stubber = Stubber(file_service._build_s3_client())
stubber.activate()
monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client)
yield stubber
stubber.assert_no_pending_responses()
@pytest.mark.parametrize(
"media_info",
[
@@ -149,3 +194,92 @@ def test_extract_audio_from_video():
assert path.name.endswith(".m4a")
finally:
path.unlink(missing_ok=True)
@pytest.mark.parametrize(
("endpoint_url", "secure_access", "expected_endpoint_url"),
[
("garage:9000", False, "http://garage:9000"),
("http://garage:9000/", False, "http://garage:9000"),
("s3.example.com", True, "https://s3.example.com"),
("http://s3.example.com", True, "https://s3.example.com"),
],
)
def test_s3_client_endpoint_follows_secure_access(
s3_settings: S3Settings,
endpoint_url: str,
secure_access: bool,
expected_endpoint_url: str,
) -> None:
"""The endpoint scheme is taken from aws_s3_secure_access, not from the URL."""
s3_settings(aws_s3_endpoint_url=endpoint_url, aws_s3_secure_access=secure_access)
assert FileService()._s3_client.meta.endpoint_url == expected_endpoint_url
@pytest.mark.parametrize("region_name", ["fr-par", None])
def test_s3_client_region_is_passed_as_is(
monkeypatch: pytest.MonkeyPatch,
s3_settings: S3Settings,
region_name: str | None,
) -> None:
"""The configured region goes straight to boto3, even when it is unset."""
s3_settings(aws_s3_region_name=region_name)
boto3_client = Mock()
monkeypatch.setattr(file_service.boto3, "client", boto3_client)
assert FileService()._s3_client is boto3_client.return_value
boto3_client.assert_called_once()
assert boto3_client.call_args.kwargs["region_name"] == region_name
def test_store_transcript(s3_stubber: Stubber) -> None:
"""The transcript is stored as JSON under the transcripts path."""
transcript = WhisperXResponse(segments=())
s3_stubber.add_response(
"put_object",
{},
{
"Bucket": "meet-media-storage",
"Key": "transcripts/job-1.json",
"Body": transcript.model_dump_json().encode(),
},
)
FileService().store_transcript(transcript=transcript, job_id="job-1")
def test_store_summary(s3_stubber: Stubber) -> None:
"""The summary is stored as text under the summaries path."""
s3_stubber.add_response(
"put_object",
{},
{
"Bucket": "meet-media-storage",
"Key": "summaries/job-1.txt",
"Body": b"The summary",
},
)
FileService().store_summary(summary="The summary", job_id="job-1")
@pytest.mark.parametrize(
("method", "expected_path"),
[
("get_transcript_signed_url", "/meet-media-storage/transcripts/job-1.json"),
("get_summary_signed_url", "/meet-media-storage/summaries/job-1.txt"),
],
)
def test_signed_urls(s3_stubber: Stubber, method: str, expected_path: str) -> None:
"""Signed URLs are path-style, SigV4-signed for the region, valid for a day."""
url = urlparse(getattr(FileService(), method)("job-1"))
query = parse_qs(url.query)
assert url.scheme == "http"
assert url.netloc == "garage:9000"
assert url.path == expected_path
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
assert "/fr-par/s3/aws4_request" in query["X-Amz-Credential"][0]
assert query["X-Amz-Expires"] == ["86400"]
+579 -577
View File
File diff suppressed because it is too large Load Diff