Compare commits

..

33 Commits

Author SHA1 Message Date
lebaudantoine 3b074783ee ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
Case-insensitive user lookups during token issuance currently take
around 100 ms in production, accounting for roughly a third of the
total 300 ms token request trace.

Add an `UPPER(email)` index on the user table so these lookups can
use the index instead of scanning, cutting the dominant cost on
the token issuance path.
2026-10-07 18:55:55 +02:00
lebaudantoine 080c347129 🔒️(backend) prevent editing client id and secret in Django admin
The client id and client secret are auto-generated with high entropy
when an application is created. Allowing them to be edited from the
Django admin let any administrator replace them with a short or
weak value, undermining that guarantee.

Make both fields read-only in the admin so they can only be
regenerated through the intended flow.
2026-10-07 18:09:30 +02:00
lebaudantoine 0450c74f53 ⚡️(backend) hash application secrets with SHA-256
Application token authentication currently uses Django's default
password hasher. On our production hardware, token requests take
at least ~500 ms. Authentication happens per user of each
application, so this cost accumulates across frequently used
integrations.

Password hashers deliberately make guessing expensive to protect
human-chosen passwords after a database leak. Our application
secrets are generated server-side using a cryptographically secure
random generator, with a default length of 128 alphanumeric
characters. Guessing these secrets is already computationally
infeasible, making password stretching an unnecessary CPU cost.

Use salted SHA-256 for application secrets while retaining
constant-time comparison, and keep user password hashing
unchanged. Existing secrets migrate after successful verification
without requiring key rotation. Conditional updates prevent
migration from overwriting a concurrent rotation.

Store the new hash in `client_secret_sha256` while preserving the
original Django hash in `client_secret`. New applications populate
both fields, allowing the previous release to authenticate both
existing and newly created applications if we need to roll back.

Once every application has migrated and the rollback window has
closed, complete the migration by removing the legacy verification
code and `client_secret` field.

This assumes securely generated, high-entropy secrets. Deployments
that reduce `APPLICATION_CLIENT_SECRET_LENGTH` or supply
predictable secrets lose the offline guessing protection that the
previous slow hasher provided.
2026-10-07 18:09:30 +02:00
briquet 2ac457c43e 🔖(minor) bump release to 1.34.0 2026-10-07 12:50:40 +02:00
lebaudantoine e9b523b522 📝(docs) document Brevo marketing migration in UPGRADE.md
Explain the switch to django-lasuite marketing: removed BREVO_*
and MARKETING_SERVICE_CLASS settings, new LASUITE_MARKETING_BACKEND
and LASUITE_MARKETING_PARAMETERS variables, dummy backend default,
and the now-required Celery worker.
2026-10-07 12:16:26 +02:00
lebaudantoine 70d348337e 📝(docs) bump UPGRADE.md after v1.33 release
The `Unreleased` section of `UPGRADE.md` was not bumped when v1.33
was released, so follow-up changes landed under the wrong heading.

Reorganize the file so v1.33 is properly closed and a fresh
`Unreleased` section is opened on top.
2026-10-07 12:16:26 +02:00
lebaudantoine 258722adfd ⏪️(docs) revert styling on heartbeat and lbheartbeat probe in CHANGELOG
Revert the formatting changes applied to the heartbeat and
lbheartbeat probe entries in the CHANGELOG, as the previous styling
was intentional.
2026-10-07 11:49:23 +02:00
lebaudantoine 0a6724e7ad 🔧(compose) make the LiveKit agents opt-in in the local dev stack
`make run` no longer starts the metadata collector and the
multi-user transcriber. On my machine, this saves about 400 MB
of RAM and almost 2% CPU, out of the 11% the stack uses.
Start them with `make run-agents` when needed.

Disable `METADATA_COLLECTOR_ENABLED` by default in `common.dist` so
the backend does not dispatch jobs to an agent that is not running,
and document how to enable each agent in `developping_locally.md`.
2026-10-06 22:56:57 +02:00
lebaudantoine c1c2d93932 🔧(compose) share the backend redis with the summary stack
The summary settings already default to the `redis` host, so
`redis-summary` was unused. Remove it and depend on `redis`.

Pin the summary Celery and task tracker URLs to DB 2 in
`summary.dist` to isolate them from LiveKit and the backend
Celery broker (DB 0) and the Django cache (DB 1).
2026-10-06 17:26:26 +02:00
lebaudantoine 9efb9577c4 ♻️(tilt) use the helm dev-backend chart
Use the shared helm `dev-backend` chart to deploy the backend in
the Tilt dev stack, instead of maintaining our own copy.

The goal is to share more common pieces of the dev experience and
dev stack across projects, so we do not maintain N different
versions of similar setups.

The only adjustment needed was to backport Garage into the shared
chart.

Originally started by @rouja.
2026-10-06 14:07:23 +02:00
Ovgodd 2137c6b444 🐛(frontend) let panel shortcuts close panels opened from a menu
allow menu-invoked panels to be closed by panel shortcuts
2026-10-05 15:11:04 +02:00
Ovgodd aa01733f6b ♿️(frontend) make participant pagination readable and keyboard reachable
Improves PaginationControl: clearer structure, keyboard nav, a11y improved.
The main room and the picture-in-picture window share this control.
Ctrl+Shift+G focuses the pagination.
2026-10-05 15:11:04 +02:00
lebaudantoine 03db669e51 🔒️(ci) set persist-credentials: false on actions/checkout
By default, `actions/checkout` saves the job's auth token
(`GITHUB_TOKEN` or the provided PAT) in the local git config so
later steps can run authenticated git commands. That token then
stays on disk for the rest of the job, where it can leak:

* If an artifact upload includes the checkout directory, the token
  is packaged with it and anyone with artifact access can extract
  it. On public repos that is anyone, and the token can be used
  while the job is still running ("ArtiPACKED", flagged by
  `zizmor` as `artipacked`).
* Any later step, third-party action, or build dependency can read
  the token from the git config, which widens the impact of a
  supply-chain compromise.

None of our workflows need authenticated git after checkout, so
disable credential persistence. If a step needs to push in
2026-10-05 12:41:38 +02:00
davd-gzl 97a73bf4f2 🔧(frontend) rename the make target to test-frontend
The frontend test target now sits beside test-back and
test-summary and follows their naming.
2026-10-05 12:41:38 +02:00
davd-gzl 267a2265d9 🔧(frontend) give vitest its own config and a make target
A separate vitest.config.ts keeps the tests off the build
plugins and the mediapipe version check in vite.config.ts.
make test now runs the frontend tests after the backend ones,
and test-front runs on Node 24, the current LTS.
2026-10-05 12:41:38 +02:00
davd-gzl 14507d57a9 ✅(frontend) add vitest so the frontend can carry unit tests
Add vitest as a dev dependency, a test script that runs panda
codegen first, and a test-front job, so the frontend can carry
unit tests. One test covers normalizeRoomId, a plain function,
so no DOM library comes with it.
2026-10-05 12:41:38 +02:00
davd-gzl c075db25a4 🐛(frontend) honour Keep hand raised when picture-in-picture is open
The hand button owns the lower-hand timer and the toast, and the
picture-in-picture window draws a second copy of that button, so two
offers go up and dismissing one leaves the other to lower the hand.
Move the watching into a component rendered once beside the room.
2026-10-04 23:21:31 +02:00
Rishi Gupta c0dfd88776 ♿️(frontend) expose loading state to assistive technology
Surface the page's loading state to assistive technology, so screen
readers can announce that the page is still loading instead of
reading a partially rendered state as if it were complete.
2026-10-04 22:58:21 +02:00
Rishi Gupta 94d2b80c17 ✏️(docs) fix README wording and typos
Address review feedback from #1 by fixing wording issues and typos
across the README.
2026-10-04 22:58:21 +02:00
KusalPabasara bd2dfcae80 ✨(helm) add envFrom support to chart
Allow every chart workload to import environment variables from ConfigMaps.

Keep existing empty defaults for backward compatibility.
2026-10-04 22:34:31 +02:00
lebaudantoine 0b4a83c92e 🐛(brevo) use django-lasuite for marketing management
When the user is updated, their lists on Brevo are overwritten with
the new value: this removes lists set by other products.

Switch to the common lib implementation from `django-lasuite`,
which manages this correctly.

This change was initially proposed by @qbey, but at the time our
deployment did not have a Celery worker running alongside the
backend. Since then, a Celery worker has been deployed, so the
switch to the common lib approach is now safe to adopt.
2026-10-04 22:10:16 +02:00
lebaudantoine 9187173cae ✨(frontend) warn users when the connection falls back to TURN
Highlight in the connection test when the user is connecting
through a TURN relay, especially over TLS or TCP. This usually
indicates that some network configuration is required on their
side, and gives them a concrete signal to pass to their IT team.

Suggested by a technical user, this is a first step toward making
users more autonomous when troubleshooting access to the tool.

Follow-up: show a similar warning in-product when we detect a
mid-meeting fallback to TURN/TLS. A one-time hint for first-time
users would likely be enough.
2026-10-03 23:36:42 +02:00
kaelvar 364bbf4f0b ✨(frontend) let signed-out visitors start a meeting
The home page only offers meeting creation to authenticated users, while the
backend already serves ephemeral rooms to anonymous visitors when
ALLOW_UNREGISTERED_ROOMS is enabled (the flag is checked in the room retrieve
view, not in the create one).

Expose the flag in the frontend configuration and, when it is on, show the
existing "Create a meeting" button to signed-out visitors. It navigates to a
freshly generated room id rather than calling POST /rooms/, which stays
reserved for registered rooms and for authenticated users.

The invite dialog opens for such a creator when the room is unregistered
(null id) and the navigation carries `create`. The `mode` computed in Room
is left untouched, so permissions and the join screen behave as before.

The home buttons row now wraps: signed-out visitors can see three controls
(create, join, login), and at the xsm breakpoint the fixed-width ProConnect
button leaves too little room for the other two.
2026-10-02 19:06:02 +02:00
lebaudantoine a6a12ef586 🐛(frontend) hide tooltips until they have a computed placement
A React Aria overlay is rendered at `top: 0; left: 0` until
`useOverlayPosition` computes its position, and `data-placement` is
only set once that succeeds. When the pointer moves quickly between
triggers, a closing tooltip can mount for its exit animation without
ever being positioned, React Aria does not retry, so it stays
stuck in the top-left corner.

Hide tooltips until they have a `data-placement` set, so unpositioned
tooltips never flash in the corner. Use `visibility` rather than
`display: none`, so the element stays measurable for the positioning
pass.
2026-10-02 18:19:43 +02:00
snyk-bot 2622d89f63 ⬆️(frontend) upgrade react-aria dependencies
Snyk has created this PR to upgrade react-stately from 3.48.0 to 3.49.0.
I had to bump react-aria@3.51.0 react-aria-components@1.20.0
2026-10-02 18:19:43 +02:00
lebaudantoine f2d50770cf 🔧(summary) add setting to control Sentry traces sampling rate
Replace the deprecated `enable_tracing=True` with `traces_sample_rate`,
read from a new `sentry_traces_sample_rate` setting (default 0.1,
validated to the 0.0–1.0 range).

Previously, tracing sampled 100% of transactions, which is costly and
unnecessary in production. The rate can now be tuned per environment
without a code change.
2026-10-02 17:41:02 +02:00
lebaudantoine 11e8470aa5 🔒️(summary) redact meeting content from Sentry events
Sentry attaches stack frame locals to its events. When storing a
transcript in S3 failed, the full transcript held in `data` and
`transcript` was sent to Sentry.

Keep locals for debugging, but scrub variables and nested dict keys
known to hold transcripts, summaries, LLM prompts, participants'
personal data or pre-signed URLs. Share the Sentry init between the
API and the Celery worker, and never send request bodies.
2026-10-02 17:41:02 +02:00
lebaudantoine 3bf78f0f5b 🐛(summary) disable default S3 checksums for GCS-compatible storage
Since boto3/botocore 1.36, the S3 client computes CRC32 checksums on
uploads by default (request_checksum_calculation="when_supported").
PutObject requests are then sent with aws-chunked encoding, a trailing
x-amz-checksum-crc32 header and a STREAMING-UNSIGNED-PAYLOAD-TRAILER
content hash.

Our production storage (S3NS, storage.s3nsapis.fr) is built on Google
Cloud Storage and exposes it through GCS's S3-compatible XML API, which
does not support these flexible checksums. It rejects the request with
a 403 SignatureDoesNotMatch ("Invalid argument"), so storing transcripts
failed in the Celery worker. Garage, used locally, supports them, which
is why the issue only appeared in production after switching the
client to boto3.

Add aws_request_checksum_calculation and
aws_response_checksum_validation settings, defaulting to
"when_required", and pass them to the botocore Config of the summary S3
client and the backend S3 client. Checksums are then only sent for
operations that require them, restoring the pre-1.36 behavior.
2026-10-02 17:41:02 +02:00
lebaudantoine ddd5e3fce1 ✏️(ci) fix a codespell in env variable
Minor typo introduced by the recording configurations.
2026-10-02 17:35:32 +02:00
lebaudantoine 5a9e1cb012 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
The nginx-unprivileged:1.30.4-alpine3.24 base image ships
pcre2 10.48-r0, which is affected by CVE-2026-103111 (HIGH,
out-of-bounds write via crafted regular expression). No newer
base image tag is available yet.

Upgrade pcre2 from the Alpine v3.24 repository with a minimum
version constraint (>=10.49-r0) so the build fails instead of
silently shipping a vulnerable version if the fix is unavailable.
2026-10-02 17:35:32 +02:00
lebaudantoine c49cee3ab4 🧑‍💻(devex) fix local recording downloads
Recordings were failing to download in the local stack because of
several small issues stacked together:

* nginx: add a `/media/recordings/` location that authorizes
  against `recordings/media-auth/`. Before, every media request
  went to `files/media-auth/`, which returned 403 for recording
  paths.
* nginx: call `proxy_hide_header Content-Disposition` before
  `add_header Content-Disposition "attachment"`. Garage stored the
  header as `inline`, which combined with nginx's value into
  `inline, attachment` and broke browser downloads.
* frontend: `mediaUrl()` now uses the frontend origin, so
  recording links go through the Vite `/media` proxy instead of
  hitting Django directly on `:8071`.
* frontend: include the file extension in the download filename.

co-author: cameldev
2026-10-02 17:35:32 +02:00
lebaudantoine bbc30de490 ⚡️(devx) switch devstack to node:22-alpine
The devstack was pulling the full `node:22` image, around 1.6 GB.
Switch to `node:22-alpine`, which is much smaller, to speed up the
devstack bootstrap time and reduce disk usage.
2026-10-02 15:59:04 +02:00
lebaudantoine 14b3395e1c ⚡️(devx) use a single Redis image version in the devstack
The devstack was pulling two different versions of the Redis image.
Align everything on a single version to save a few MB of network
bandwidth when bootstrapping the stack.

Late-night minor optimization.
2026-10-02 15:59:04 +02:00
138 changed files with 2466 additions and 2806 deletions
+31
View File
@@ -88,6 +88,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Create writable /data
run: |
@@ -159,6 +161,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install ffmpeg
run: |
@@ -186,6 +190,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
@@ -196,6 +202,27 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
test-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "24"
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
- name: Run tests
run: cd src/frontend/ && npm test
lint-sdk:
runs-on: ubuntu-latest
permissions:
@@ -206,6 +233,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: npm ci --ignore-scripts
@@ -227,6 +256,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: npm ci --ignore-scripts
+31 -5
View File
@@ -10,14 +10,33 @@ and this project adheres to
### Added
- 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation
- ✨(backend) add room soft-deletion to the external API
- ✨(backend) purge soft-deleted rooms after a retention period
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
### Changed
- ♻️(backend) soft delete rooms instead of hard-delete
- ⚡️(backend) hash application secrets with SHA-256
### Fixed
- 🔒️(backend) prevent editing client id and secret in Django admin
## [1.34.0] - 2026-10-07
### Added
- ✨(helm) import environment variables from Secrets and ConfigMaps
- 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation
- 🔧(summary) add setting to control Sentry traces sampling rate
- ✨(frontend) let signed-out visitors start a meeting
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
- ✅(frontend) add vitest so the frontend can carry unit tests
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
### Changed
- ✨(frontend) warn users when the connection falls back to TURN
- 🔧(backend) configure the technical documentation url
### Fixed
@@ -25,6 +44,13 @@ and this project adheres to
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
- 🔒️(summary) redact meeting content from Sentry events
- 🐛(frontend) hide tooltips until they have a computed placement
- 🐛(brevo) use django-lasuite for marketing management
- ♿️(frontend) expose loading state to assistive technology
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
## [1.33.0] - 2026-09-30
+2 -3
View File
@@ -37,14 +37,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:22 AS mail-builder
FROM node:22-alpine AS mail-builder
COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
RUN npm ci --ignore-scripts && npm run build
# ---- static link collector ----
+7 -3
View File
@@ -169,7 +169,7 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent
run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
@$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector
.PHONY: run-agents
@@ -186,7 +186,6 @@ run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -260,7 +259,8 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
$(MAKE) test-summary ARGS="$${args}" && \
$(MAKE) test-frontend
.PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@@ -278,6 +278,10 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
bin/pytest-summary $${args}
.PHONY: test-summary
test-frontend: ## run the frontend unit tests
cd $(PATH_FRONT) && npm test
.PHONY: test-frontend
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+3 -3
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- LiveKit advanced features including:
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
+76 -33
View File
@@ -16,51 +16,54 @@ the following command inside your docker container:
## [Unreleased]
### Purging inactive rooms
### Marketing / Brevo integration now uses `django-lasuite`
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 in-house marketing service (`core.services.marketing`) has been removed and
replaced by the shared implementation from `django-lasuite`
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
Brevo overwrote their list memberships, removing lists set by other
La Suite products. Existing lists are now preserved and merged.
- 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.
**Celery worker required.** Newsletter signup on login
(`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
running alongside the backend, otherwise contacts will never be pushed to Brevo.
* 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.
**Configuration changes.** The following environment variables / settings are
**removed** and no longer read:
### Purging deleted rooms
- `MARKETING_SERVICE_CLASS`
- `BREVO_API_KEY`
- `BREVO_API_CONTACT_LIST_IDS`
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
- `BREVO_API_TIMEOUT`
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
- Inactive soft-deleted rooms are purged along with the other inactive rooms: if `ROOM_INACTIVITY_DELETION_DAYS` is already set, nothing else is needed.
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
| Variable | Default | Description |
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
### Local development: MinIO replaced by Garage
⚠️ The default backend is now a **dummy** (no-op). If you previously used
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
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.
LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
To migrate a local environment:
Migration mapping:
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
- `BREVO_API_KEY` → `api_key`
- `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
- `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
`{"VISIO_USER": True}` if you relied on the old default)
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
### Summary service and metadata collector: boto3 replaces the minio client
Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
secret (e.g. build the whole variable from your secret store).
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`.
### Recording encoding settings replaced by a resolution/profile model
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
@@ -183,6 +186,46 @@ Before enabling it:
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
for the full setting reference, the shipped profile table and the tuning caveats.
## v1.33.0
### 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`. 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
+9 -8
View File
@@ -104,15 +104,16 @@ k8s_yaml(secret_yaml_generic(
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
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'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('dev-backend-garage-cors', resource_deps=['dev-backend-garage'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('meet-backend', resource_deps=['dev-backend-postgres', 'dev-backend-garage-cors', 'dev-backend-redis', 'dev-backend-keycloak', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summarize', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
# Trigger once on launch
k8s_resource(
'meet-backend-createsuperuser',
+7 -9
View File
@@ -9,6 +9,8 @@ services:
redis:
image: redis:5
ports:
- "6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
@@ -153,6 +155,7 @@ services:
target: frontend-production
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_MEDIA_BASE_URL: "http://localhost:8083"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
@@ -172,7 +175,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:22-alpine
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -270,11 +273,6 @@ services:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
@@ -289,7 +287,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
celery-summary-transcribe:
container_name: celery-summary-transcribe
@@ -303,7 +301,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- garage
develop:
@@ -323,7 +321,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- garage
develop:
+1
View File
@@ -58,6 +58,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
apk del curl
USER nginx
@@ -4,6 +4,36 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for recordings (authorized by the recordings viewset)
location /media/recordings/ {
auth_request /media-auth-recordings;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
proxy_pass http://garage:9000/meet-media-storage/recordings/;
proxy_set_header Host garage:9000;
proxy_hide_header Content-Disposition;
add_header Content-Disposition "attachment";
}
location = /media-auth-recordings {
internal;
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
+31
View File
@@ -107,6 +107,37 @@ $ npm i
$ npm run dev
```
### LiveKit agents (optional)
The LiveKit agents are not started by `make run`. Each one runs its own
container and stays connected to LiveKit, which costs CPU and memory you
don't need unless you work on the features they power. Start them only
when you need them.
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|---|---|---|---|
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
To start both at once:
```shellscript
$ make run-agents
```
Then set the matching settings to `True` and restart the backend so it
picks them up:
```shellscript
$ make run-backend
```
The multi-user transcriber also needs a speech-to-text provider. Configure
`STT_PROVIDER` and its credentials in
`env.d/development/multi_user_transcriber`.
Keep the settings and the agents in sync: if a setting is `True` while its
agent is stopped, the backend still dispatches jobs to it and the feature
fails silently.
---
## Adding Content
+5 -11
View File
@@ -1,6 +1,6 @@
# 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, and the rooms deleted by their owner for longer than a configurable retention period. It is disabled by default.
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
@@ -12,21 +12,16 @@ A room is inactive when:
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.
Deleting a room through the API only soft deletes it: the room is hidden, its link tells visitors the meeting was deleted, and it keeps its slug and PIN code. A soft-deleted room is purged:
- when it is inactive, as described above: a deleted room is never considered started again, so its inactivity period keeps running, or
- when it was deleted more than `ROOM_DELETED_RETENTION_DAYS` days ago.
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` or `ROOM_DELETED_RETENTION_DAYS` is set.
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 and deleted rooms
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. A room is kept, past its inactivity or retention period, as long as it holds a saved recording its users may still access:
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.
@@ -38,8 +33,7 @@ The room is deleted from the database, along with its accesses, its telephony PI
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. This also applies to a soft-deleted room: once purged, its link
no longer tells visitors the meeting was deleted, and its PIN code can be given to another room.
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.
+1 -1
View File
@@ -347,6 +347,7 @@ These are the environmental options available on meet backend.
| 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_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are 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 |
@@ -406,7 +407,6 @@ These are the environmental options available on meet backend.
| 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 | |
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
| 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"} |
+5 -4
View File
@@ -88,7 +88,7 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
# RECORDING_ENCODING_DEFAULT_PROFILE=full
# Default encoding values independant of resolution/profile
# Default encoding values independent of resolution/profile
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
@@ -104,10 +104,11 @@ ROOM_TELEPHONY_ENABLED=True
# ROOMKIT_ENABLED = True
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
# Metadata
METADATA_COLLECTOR_ENABLED=True
# LiveKit agents (opt-in, start them with `make run-agents`)
# Metadata (requires the metadata-collector agent)
METADATA_COLLECTOR_ENABLED=False
# Subtitle
# Subtitle (requires the multi-user-transcriber agent)
ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False
+4
View File
@@ -9,6 +9,10 @@ AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
CELERY_BROKER_URL="redis://redis:6379/2"
CELERY_RESULT_BACKEND="redis://redis:6379/2"
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.33.0"
version = "1.34.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.33.0"
version = "1.34.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
+5
View File
@@ -483,6 +483,11 @@ 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):
-1
View File
@@ -9,7 +9,6 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
ROOM_DELETED = "room_deleted"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+1
View File
@@ -73,6 +73,7 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
},
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
-30
View File
@@ -1,30 +0,0 @@
"""Exceptions and guards shared by the API endpoints."""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, status
from core import models
class RoomSoftDeleted(exceptions.APIException):
"""Raised when the requested room has been soft deleted."""
status_code = status.HTTP_410_GONE
default_detail = _("This room has been deleted.")
default_code = "room_deleted"
def ensure_room_not_deleted(resource):
"""Raise a 410 Gone if the resource is a soft-deleted room.
Accepts a room or its parent resource, as referenced by accesses. Call it
after permissions are checked, to avoid revealing room.
"""
if isinstance(resource, models.Room):
room = resource
else:
room = getattr(resource, "room", None)
if room is not None and room.is_deleted:
raise RoomSoftDeleted()
-2
View File
@@ -25,7 +25,6 @@ from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.api.exceptions import ensure_room_not_deleted
logger = logging.getLogger(__name__)
@@ -114,7 +113,6 @@ class ResourceAccessSerializerMixin:
_("You must be administrator or owner of a room to add accesses to it.")
)
ensure_room_not_deleted(resource)
return resource
+5 -33
View File
@@ -42,7 +42,6 @@ from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.exceptions import ensure_room_not_deleted
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -77,7 +76,7 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.room_management import RoomManagement
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
@@ -168,7 +167,7 @@ class UserViewSet(
)
class RoomViewSet( # pylint: disable=too-many-public-methods
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
@@ -194,9 +193,10 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
filter_kwargs = {"pk": self.kwargs["pk"]}
except ValueError:
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
queryset = self.filter_queryset(self.get_queryset())
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
ensure_room_not_deleted(obj)
return obj
def retrieve(self, request, *args, **kwargs):
@@ -248,27 +248,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_destroy(self, instance):
"""Soft delete the room and close its LiveKit room.
The room and its recordings are kept in database for traceability.
"""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_DELETED,
{
"room_id": str(instance.pk),
"access_level": instance.access_level,
},
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.
@@ -961,13 +940,6 @@ class ResourceAccessViewSet(
return queryset
def get_object(self):
"""Accesses to a soft-deleted room can be read but no longer modified."""
access = super().get_object()
if self.request.method not in drf_permissions.SAFE_METHODS:
ensure_room_not_deleted(access.resource)
return access
class RecordingViewSet(
mixins.DestroyModelMixin,
+8 -25
View File
@@ -1,26 +1,19 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from rest_framework.authentication import SessionAuthentication
from core.models import User
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
"""Add the user to the newsletter list on sign-in.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
Uses the team's standard Brevo integration, dispatching the contact
creation/update as an asynchronous task to keep authentication fast.
"""
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
create_or_update_contact.delay(
email=email,
attributes={"VISIO_SOURCE": ["SIGNIN"]},
)
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
+3 -32
View File
@@ -4,7 +4,6 @@ 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
@@ -22,13 +21,11 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.generics import get_object_or_404
from core import analytics, api, models
from core.api.exceptions import ensure_room_not_deleted
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.room_management import RoomManagement
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -76,7 +73,7 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not check_password(client_secret, application.client_secret):
if not application.check_client_secret(client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
@@ -144,7 +141,6 @@ class ApplicationViewSet(viewsets.ViewSet):
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
@@ -162,13 +158,9 @@ class RoomViewSet(
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
- destroy: Soft delete a room and close its LiveKit room, for owners only
(requires 'rooms:delete' scope)
Detail operations on a soft-deleted room answer 410 Gone.
"""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
http_method_names = ["get", "post", "patch", "head", "options"]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
@@ -183,17 +175,6 @@ class RoomViewSet(
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def get_object(self):
"""Get the room, answer 410 if it has been deleted.
Permissions are checked first so a deleted room is only revealed to
users who would have been granted access to it.
"""
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
self.check_object_permissions(self.request, room)
ensure_room_not_deleted(room)
return room
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
@@ -257,16 +238,6 @@ class RoomViewSet(
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
def perform_destroy(self, instance):
"""Soft delete the room, close its LiveKit room, then log and track it."""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
+10
View File
@@ -7,6 +7,8 @@ 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__)
@@ -24,6 +26,14 @@ 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
@@ -0,0 +1,46 @@
"""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" # noqa: S105
CLIENT_SECRET_HASH_VERSION = "v0" # noqa: S105
CLIENT_SECRET_HASH_PREFIX = (
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))
@@ -1,8 +1,6 @@
"""Purge inactive and soft-deleted rooms."""
"""Purge inactive rooms."""
import operator
from datetime import timedelta
from functools import reduce
from itertools import batched
from logging import getLogger
@@ -24,12 +22,10 @@ class Command(BaseCommand):
- rooms which were last started before that period
- rooms never started and created before that period
Also delete rooms soft deleted more than ROOM_DELETED_RETENTION_DAYS days ago.
Rooms holding a saved recording that has not expired are kept.
"""
help = "Purge inactive and soft-deleted rooms"
help = "Purge inactive rooms"
def add_arguments(self, parser):
parser.add_argument(
@@ -39,68 +35,53 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
"""Browse purgeable rooms and delete them chunk by chunk."""
"""Browse inactive rooms and delete them chunk by chunk."""
if not (
settings.ROOM_INACTIVITY_DELETION_DAYS
or settings.ROOM_DELETED_RETENTION_DAYS
):
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
self.stdout.write(
"Purging rooms is disabled (neither ROOM_INACTIVITY_DELETION_DAYS "
"nor ROOM_DELETED_RETENTION_DAYS is set)."
"Purging inactive rooms is disabled "
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
)
return
now = timezone.now()
purgeable_rooms = self.get_purgeable_rooms(now)
inactive_rooms = self.get_inactive_rooms(now)
purgeable_count = purgeable_rooms.count()
if not purgeable_count:
self.stdout.write("No room to purge.")
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] {purgeable_count} room(s) would be purged:")
rooms = purgeable_rooms.values_list("name", "deleted_at")
for name, deleted_at in rooms.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}{' (deleted)' if deleted_at else ''}")
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 = purgeable_rooms.values_list("pk", "slug", "deleted_at").iterator(
chunk_size=CHUNK_SIZE
)
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, deleted_at in chunk:
state = "deleted" if deleted_at else "inactive"
logger.info("Purging %s room %s (%s)", state, room_id, slug)
for room_id, slug in chunk:
logger.info("Purging inactive room %s (%s)", room_id, slug)
_, deleted_by_model = purgeable_rooms.filter(
pk__in=[room_id for room_id, _, _ in chunk]
_, 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} room(s).")
self.stdout.write(f"Purged {purged_count} inactive room(s).")
@staticmethod
def get_purgeable_rooms(now):
"""Return the inactive or long-deleted rooms that no recording protects.
def get_inactive_rooms(now):
"""Return the rooms inactive for too long that no recording protects."""
Soft-deleted rooms are hidden by `Room.objects`, hence `Room.all_objects`.
"""
conditions = []
if settings.ROOM_INACTIVITY_DELETION_DAYS:
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
conditions.append(
Q(last_started_at__lt=threshold)
| Q(last_started_at__isnull=True, created_at__lt=threshold)
)
if settings.ROOM_DELETED_RETENTION_DAYS:
deleted_before = now - timedelta(days=settings.ROOM_DELETED_RETENTION_DAYS)
conditions.append(Q(deleted_at__lt=deleted_before))
if not conditions:
return Room.all_objects.none()
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()
@@ -110,6 +91,4 @@ class Command(BaseCommand):
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
)
return Room.all_objects.filter(
reduce(operator.or_, conditions), ~Exists(protected_recordings)
)
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
@@ -0,0 +1,19 @@
"""Add a separate fast hash while preserving legacy credentials for rollback."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_room_last_started_at"),
]
operations = [
migrations.AddField(
model_name="application",
name="client_secret_sha256",
field=models.CharField(
max_length=255, null=True, blank=True
),
),
]
@@ -1,29 +0,0 @@
# Generated by Django 5.2.14 on 2026-09-16 10:00
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_room_last_started_at'),
]
operations = [
migrations.AddField(
model_name='room',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterModelOptions(
name='room',
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
),
migrations.AlterModelManagers(
name='room',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-10-07 18:45
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0025_application_client_secret_sha256'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(
Upper("email"),
name="user_email_upper_idx",
),
),
]
+66 -52
View File
@@ -14,6 +14,7 @@ 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
@@ -25,7 +26,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, utils
from . import fields, hashers, utils
from .recording.enums import FileExtension
from .validators import sub_validator
@@ -245,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name="unique_email_when_sub_is_null",
)
]
indexes = [
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -406,33 +410,6 @@ class ResourceAccess(BaseModel):
return super().delete(*args, **kwargs)
class RoomQuerySet(models.QuerySet):
"""QuerySet exposing the room lifecycle filters."""
def active(self):
"""Rooms that have not been soft deleted."""
return self.filter(deleted_at__isnull=True)
def deleted(self):
"""Rooms that have been soft deleted."""
return self.filter(deleted_at__isnull=False)
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
It is deliberately not the model's default manager: Django relies on
``_default_manager`` for unique validation, which must see deleted rooms as
they still guard their slug and pin code. Other object relations (e.g.
``recording.room``) go through the base manager and still resolve deleted
rooms, which keeps recordings and their notifications working.
"""
def get_queryset(self):
"""Exclude soft-deleted rooms."""
return super().get_queryset().active()
class Room(Resource):
"""Model for one room"""
@@ -456,7 +433,6 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
deleted_at = models.DateTimeField(null=True, blank=True)
pin_code = models.CharField(
max_length=None,
unique=True,
@@ -473,13 +449,8 @@ class Room(Resource):
editable=False,
)
# Managers
objects = RoomManager()
all_objects = models.Manager.from_queryset(RoomQuerySet)()
class Meta:
db_table = "meet_room"
default_manager_name = "all_objects"
ordering = ("name",)
verbose_name = _("Room")
verbose_name_plural = _("Rooms")
@@ -502,23 +473,6 @@ class Room(Resource):
)
super().save(*args, **kwargs)
@property
def is_deleted(self):
"""Whether the room has been soft deleted."""
return self.deleted_at is not None
def soft_delete(self):
"""Soft delete the room.
The room is hidden from the default manager making it impossible to
list, join or update.
"""
if self.deleted_at:
raise RuntimeError("This room is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -554,7 +508,7 @@ class Room(Resource):
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.all_objects.filter(pin_code=pin_code).exists():
if not Room.objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
@@ -874,6 +828,9 @@ 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,
@@ -889,6 +846,63 @@ 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."""
+10 -33
View File
@@ -271,24 +271,20 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.all_objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# The block below is intended to fix the issue where long-lived livekit
# tokens allow users who already entered a room to re-create it,
# even if it was closed.
if room.is_deleted:
self._close_deleted_room(room_id)
return
# Update through the queryset to skip the full_clean run by save()
models.Room.all_objects.filter(pk=room.pk).update(
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:
@@ -296,25 +292,6 @@ class LiveKitEventsService:
f"Failed to create sip dispatch rule for room {room_id}"
) from e
@staticmethod
def _close_deleted_room(room_id):
"""Close a LiveKit room recreated after its room was soft deleted.
LiveKit auto-creates a room on join, so a participant still holding a
valid token can bring a deleted room back to life until the token expires.
"""
logger.warning(
"LiveKit room %s started for a deleted room, closing it", room_id
)
try:
RoomManagement.delete_room(str(room_id))
except RoomNotFoundException:
logger.info("LiveKit room %s is already closed", room_id)
except RoomManagementException as e:
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
-138
View File
@@ -1,138 +0,0 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -2,13 +2,10 @@
# pylint: disable=no-name-in-module
import contextlib
import json
from logging import getLogger
from typing import Dict, Optional
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
@@ -18,9 +15,6 @@ from livekit.api import (
)
from core import utils
from core.models import Room
from .sip_management import SIPException, SIPManagement
logger = getLogger(__name__)
@@ -121,43 +115,6 @@ class RoomManagement:
finally:
await lkapi.aclose()
@classmethod
def soft_delete(cls, room: Room):
"""Soft delete a room, then close its LiveKit room and its SIP routing.
Raises:
RoomManagementException: the LiveKit room could not be closed, or its
SIP dispatch rule could not be deleted.
"""
room.soft_delete()
try:
with contextlib.suppress(RoomNotFoundException):
cls.delete_room(str(room.id))
cls._delete_dispatch_rule(room)
except Exception:
Room.all_objects.filter(pk=room.pk, deleted_at=room.deleted_at).update(
deleted_at=None
)
room.deleted_at = None
raise
@staticmethod
def _delete_dispatch_rule(room: Room):
"""Delete a room's SIP dispatch rule, so its PIN no longer routes calls.
This cannot be left to the room_finished webhook: a roomkit join creates
the rule before any LiveKit room exists, so no room may ever finish.
"""
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
SIPManagement().delete_dispatch_rule(room.id)
except SIPException as e:
raise RoomManagementException("Could not delete dispatch rule") from e
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
+4 -15
View File
@@ -124,11 +124,7 @@ class SIPManagement:
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room.
A rule deleted meanwhile (e.g. by both a room deletion and its
room_finished webhook) is not an error.
"""
"""Delete all SIP inbound dispatch rules associated with a specific room."""
rules_ids = await self._list_dispatch_rules_ids(room_id)
@@ -142,16 +138,9 @@ class SIPManagement:
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
try:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(
sip_dispatch_rule_id=rule_id
)
)
except TwirpError as e:
if e.code != TwirpErrorCode.NOT_FOUND:
raise
logger.info("Dispatch rule %s was already deleted", rule_id)
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
return True
@@ -2,14 +2,14 @@
from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import SuspiciousOperation
import pytest
from lasuite.marketing.tasks import create_or_update_contact
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
@mock.patch.object(create_or_update_contact, "delay")
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
"""Test successful marketing signup."""
email = "test@example.com"
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
mock_create_or_update_contact.assert_called_once_with(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
@@ -9,7 +9,6 @@ from django.core.management import call_command
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
@@ -20,25 +19,14 @@ COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
BEFORE_PERIOD = timedelta(days=366)
WITHIN_PERIOD = timedelta(days=364)
BEFORE_RETENTION = timedelta(days=31)
WITHIN_RETENTION = timedelta(days=29)
@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.ROOM_DELETED_RETENTION_DAYS = None
settings.RECORDING_EXPIRATION_DAYS = 30
@pytest.fixture(name="retention_only")
def fixture_retention_only(settings):
"""Only purge the rooms soft deleted more than 30 days ago."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
settings.ROOM_DELETED_RETENTION_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):
@@ -53,12 +41,12 @@ def call_purge(*args):
def room_exists(room):
"""Tell whether the room is still in database, soft deleted or not."""
return models.Room.all_objects.filter(pk=room.pk).exists()
"""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 neither period is configured."""
"""Should delete nothing when no inactivity period is configured."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
@@ -80,7 +68,7 @@ def test_purge_inactive_rooms_without_recording_expiration(settings):
status=models.RecordingStatusChoices.SAVED,
)
assert call_purge() == "Purged 1 room(s).\n"
assert call_purge() == "Purged 1 inactive room(s).\n"
assert not room_exists(room)
assert room_exists(room_with_recording)
@@ -109,7 +97,7 @@ def test_purge_inactive_rooms_started_before_period(caplog):
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
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
@@ -132,7 +120,7 @@ def test_purge_inactive_rooms_started_within_period():
last_started_at=now - WITHIN_PERIOD,
)
assert call_purge() == "No room to purge.\n"
assert call_purge() == "No inactive room to purge.\n"
assert room_exists(room)
@@ -141,7 +129,7 @@ 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 room to purge.\n"
assert call_purge() == "No inactive room to purge.\n"
assert room_exists(room)
@@ -233,7 +221,7 @@ def test_purge_inactive_rooms_dry_run():
factories.RoomFactory(name="Recent room")
assert call_purge("--dry-run") == (
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room\n"
"[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)
@@ -247,134 +235,5 @@ def test_purge_inactive_rooms_several_chunks():
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
output = call_purge()
assert output == "Purged 5 room(s).\n"
assert output == "Purged 5 inactive room(s).\n"
assert not any(room_exists(room) for room in rooms)
def test_purge_inactive_rooms_soft_deleted(caplog):
"""Should delete an inactive room even if it was soft deleted recently."""
now = timezone.now()
room = create_at(
now - BEFORE_PERIOD,
factories.RoomFactory,
deleted_at=now - timedelta(days=1),
)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_before_retention(caplog):
"""Should delete a room soft deleted before the retention period."""
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_within_retention():
"""Should keep a room soft deleted within the retention period."""
room = factories.RoomFactory(deleted_at=timezone.now() - WITHIN_RETENTION)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_retention_only_keeps_inactive_rooms():
"""Should keep inactive rooms that are not deleted when only retention is set."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
def test_purge_deleted_rooms_within_retention_but_inactive(settings):
"""Should delete an inactive room even if it was deleted within the retention."""
settings.ROOM_DELETED_RETENTION_DAYS = 30
now = timezone.now()
room = create_at(
now - BEFORE_PERIOD,
factories.RoomFactory,
deleted_at=now - WITHIN_RETENTION,
)
call_purge()
assert not room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_recording_not_expired():
"""Should keep a deleted room holding a saved recording that has not expired."""
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_recording_expired():
"""Should delete a deleted room along with its expired recordings."""
now = timezone.now()
room = create_at(
now - timedelta(days=40),
factories.RoomFactory,
deleted_at=now - BEFORE_RETENTION,
)
recording = create_at(
now - timedelta(days=40),
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()
def test_purge_rooms_dry_run_flags_deleted_rooms(settings):
"""Should flag the soft-deleted rooms listed on a dry run."""
settings.ROOM_DELETED_RETENTION_DAYS = 30
now = timezone.now()
rooms = [
create_at(now - BEFORE_PERIOD, factories.RoomFactory, name="Alpha room"),
factories.RoomFactory(name="Beta room", deleted_at=now - BEFORE_RETENTION),
]
factories.RoomFactory(name="Gamma room", deleted_at=now - WITHIN_RETENTION)
assert call_purge("--dry-run") == (
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room (deleted)\n"
)
assert all(room_exists(room) for room in rooms)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_frees_slug():
"""Should let a new room take the slug of a purged deleted room."""
factories.RoomFactory(name="my room", deleted_at=timezone.now() - BEFORE_RETENTION)
client = APIClient()
client.force_login(factories.UserFactory())
call_purge()
response = client.post("/api/v1.0/rooms/", {"name": "My Room!"})
assert response.status_code == 201
assert models.Room.objects.get().slug == "my-room"
@@ -116,29 +116,6 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.json() == {"slug": ["Room with this Slug already exists."]}
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
"""
A deleted room keeps its slug: creating a room with the same name should
fail validation rather than hit the database constraint.
"""
RoomFactory(name="my room").soft_delete()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "My Room!",
},
)
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
assert Room.all_objects.count() == 1
def test_api_rooms_create_authenticated_user_default_access_level():
"""
The user's default room access level should be applied to the new room
@@ -2,16 +2,11 @@
Test rooms API endpoints in the Meet core app: delete.
"""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...analytics import AnalyticsEvent
from ...factories import RoomFactory, UserFactory
from ...models import Room, RoomAccessLevel
from ...services.room_management import RoomManagement, RoomNotFoundException
from ...services.sip_management import SIPManagement
from ...models import Room
pytestmark = pytest.mark.django_db
@@ -88,52 +83,12 @@ def test_api_rooms_delete_administrators():
assert Room.objects.count() == 1
@mock.patch("core.api.viewsets.analytics.capture")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_owners(mock_delete_room, _, mock_capture):
def test_api_rooms_delete_owners():
"""
Authenticated users should be able to delete a room for which they are directly
owner. The room is soft deleted, its LiveKit room is closed and a ROOM_DELETED
analytics event is emitted.
owner.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
mock_capture.assert_called_once_with(
user,
AnalyticsEvent.ROOM_DELETED,
{"room_id": str(room.pk), "access_level": RoomAccessLevel.TRUSTED},
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_owners_room_not_live(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""
Deleting a room that is not live in LiveKit should still soft delete it, and
delete its SIP dispatch rule, as no room_finished webhook will.
"""
settings.ROOM_TELEPHONY_ENABLED = True
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
@@ -144,47 +99,4 @@ def test_api_rooms_delete_owners_room_not_live(
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""
Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 403
mock_delete_room.assert_not_called()
@@ -188,28 +188,6 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
assert response.json() == {"detail": "No Room matches the given query."}
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
@pytest.mark.parametrize("lookup", ["id", "slug"])
@mock.patch("core.utils.generate_token", return_value="foo")
def test_api_rooms_retrieve_soft_deleted(
mock_token, lookup, allow_unregistered_rooms, settings
):
"""
Retrieving a soft-deleted room should return a 410, and never fall back
to an unregistered room with the same slug.
"""
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
room.soft_delete()
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_token.assert_not_called()
@mock.patch("core.utils.generate_token", return_value="foo")
@override_settings(
LIVEKIT_CONFIGURATION={
@@ -1,7 +1,7 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611, C0302
# pylint: disable=W0621,W0613, W0212, E0611
import logging
import uuid
@@ -27,10 +27,7 @@ from core.services.livekit_events import (
to_recording_event,
)
from core.services.lobby import LobbyService
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_management import RoomManagementException
from core.services.sip_management import (
SIPException,
SIPManagement,
@@ -799,61 +796,6 @@ def test_handle_room_started_raises_error_for_nonexistent_room(service):
service._handle_room_started(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch("core.services.room_management.RoomManagement.delete_room")
def test_handle_room_started_closes_deleted_room(
mock_delete_room, mock_ensure_dispatch_rule, service, settings
):
"""Should close a LiveKit room recreated for a soft-deleted room."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_delete_room.assert_called_once_with(str(room.id))
mock_ensure_dispatch_rule.assert_not_called()
@mock.patch(
"core.services.room_management.RoomManagement.delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_handle_room_started_ignores_already_closed_deleted_room(
mock_delete_room, service
):
"""Should proceed silently when the deleted room is already closed in LiveKit."""
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_delete_room.assert_called_once_with(str(room.id))
@mock.patch(
"core.services.room_management.RoomManagement.delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_handle_room_started_raises_error_when_closing_deleted_room_fails(
mock_delete_room, service
):
"""Should raise ActionFailedError when the deleted room cannot be closed."""
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
expected_error = f"Failed to close deleted room {room.id}"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
@@ -1,212 +0,0 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@mock.patch("core.services.marketing.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
@@ -2,19 +2,16 @@
from unittest import mock
from django.db import connection
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import Room, RoomAccessLevel
from core.models import RoomAccessLevel
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.sip_management import SIPException, SIPManagement
@mock.patch("core.services.room_management.utils.create_livekit_client")
@@ -65,151 +62,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
mock_api.aclose.assert_awaited_once()
@pytest.mark.django_db(transaction=True)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_commits_before_closing_livekit_room(mock_delete_room, _):
"""The deletion is committed before the LiveKit room is closed, so the
room_started webhook of a participant reconnecting right away sees it."""
room = RoomFactory()
def assert_deletion_committed(room_name):
assert connection.in_atomic_block is False
assert Room.all_objects.get(id=room_name).is_deleted
mock_delete_room.side_effect = assert_deletion_committed
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
@pytest.mark.django_db
@pytest.mark.parametrize(
"error",
[
RoomManagementException("Could not delete room"),
ConnectionError("LiveKit is unreachable"),
],
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_failure_rolls_back_and_can_be_retried(mock_delete_room, _, error):
"""A failed soft delete leaves the room untouched, in database and in memory,
so it can be retried."""
room = RoomFactory()
mock_delete_room.side_effect = error
with pytest.raises(type(error)):
RoomManagement.soft_delete(room)
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
mock_delete_room.side_effect = None
RoomManagement.soft_delete(room)
assert mock_delete_room.call_count == 2
assert room.deleted_at is not None
assert Room.all_objects.get(id=room.id).deleted_at is not None
assert Room.objects.filter(id=room.id).exists() is False
@pytest.mark.django_db
@pytest.mark.parametrize("sip_setting", ["ROOM_TELEPHONY_ENABLED", "ROOMKIT_ENABLED"])
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_soft_delete_room_not_live_deletes_dispatch_rule(
mock_delete_room, mock_delete_dispatch_rule, sip_setting, settings
):
"""A roomkit join creates the dispatch rule before any LiveKit room exists,
so no room_finished webhook deletes it: the soft delete must, or the
retained PIN would still route SIP calls to the deleted room."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
setattr(settings, sip_setting, True)
room = RoomFactory()
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.all_objects.get(id=room.id).is_deleted
@pytest.mark.django_db
def test_soft_delete_live_room_deletes_dispatch_rule_after_closing_it(settings):
"""The dispatch rule of a live room is deleted only once the LiveKit room is
closed, so it keeps routing calls if the closing fails and the deletion is
rolled back."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
calls = mock.Mock()
with (
mock.patch.object(RoomManagement, "delete_room", calls.delete_room),
mock.patch.object(
SIPManagement, "delete_dispatch_rule", calls.delete_dispatch_rule
),
):
RoomManagement.soft_delete(room)
assert calls.mock_calls == [
mock.call.delete_room(str(room.id)),
mock.call.delete_dispatch_rule(room.id),
]
@pytest.mark.django_db
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_sip_disabled_skips_dispatch_rule(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""Without telephony nor roomkit, no dispatch rule is looked up."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_not_called()
assert Room.all_objects.get(id=room.id).is_deleted
@pytest.mark.django_db
@mock.patch.object(
SIPManagement,
"delete_dispatch_rule",
side_effect=SIPException("Could not delete dispatch rules"),
)
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_soft_delete_dispatch_rule_failure_rolls_back(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""A dispatch rule that can't be deleted fails the soft delete like a LiveKit
room that can't be closed: it is rolled back so it can be retried."""
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
with pytest.raises(RoomManagementException):
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
@@ -257,31 +257,6 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_already_deleted(mock_client_factory, mock_list_rules):
"""A rule deleted between listing and deletion (e.g. by both a room deletion and
its room_finished webhook) should not fail, nor stop the other deletions."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=[
TwirpError(msg="sip dispatch rule not found", code="not_found", status=404),
None,
]
)
mock_client_factory.return_value = mock_api
result = sip_management.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
@@ -2,8 +2,6 @@
Test resource accesses API endpoints in the Meet core app.
"""
# pylint: disable=too-many-lines
import random
from unittest import mock
from uuid import uuid4
@@ -958,99 +956,3 @@ def test_api_room_user_access_delete_owners_last_owner():
assert response.status_code == 403
assert ResourceAccess.objects.count() == 1
# Soft-deleted rooms
def test_api_room_user_accesses_create_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to add accesses to it.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/resource-accesses/",
{
"user": str(UserFactory().id),
"resource": str(room.id),
"role": "member",
},
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
assert ResourceAccess.objects.count() == 1
def test_api_room_user_accesses_create_soft_deleted_room_not_administrator():
"""
Users without privileges on a soft-deleted room should get a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/resource-accesses/",
{
"user": str(UserFactory().id),
"resource": str(room.id),
"role": "member",
},
)
assert response.status_code == 403
assert ResourceAccess.objects.count() == 1
def test_api_room_user_accesses_update_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to update its accesses.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
access = UserResourceAccessFactory(resource=room, role="member")
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/resource-accesses/{access.id!s}/",
{"role": "administrator"},
format="json",
)
assert response.status_code == 410
access.refresh_from_db()
assert access.role == "member"
def test_api_room_user_access_delete_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to remove its accesses.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
access = UserResourceAccessFactory(resource=room, role="member")
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/resource-accesses/{access.id!s}/",
)
assert response.status_code == 410
assert ResourceAccess.objects.filter(id=access.id).exists() is True
@@ -0,0 +1,350 @@
"""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
@@ -26,12 +26,7 @@ from core.models import (
RoomAccessLevel,
User,
)
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.sip_management import SIPManagement
from core.services.room_management import RoomManagement
pytestmark = pytest.mark.django_db
@@ -568,53 +563,6 @@ def test_api_rooms_retrieve_not_found():
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_retrieve_invalid_id():
"""Retrieving a room with a malformed id should return a 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/not-a-uuid/")
assert response.status_code == 404
def test_api_rooms_retrieve_soft_deleted():
"""Retrieving a soft-deleted room should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
def test_api_rooms_retrieve_soft_deleted_not_member():
"""Retrieving a soft-deleted room without any role on it should return a 403,
not revealing that the room has been deleted."""
user = UserFactory()
room = RoomFactory()
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
def test_api_rooms_create_requires_authentication():
"""Creating rooms without authentication should return 401."""
@@ -1444,211 +1392,6 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_requires_authentication(mock_delete_room):
"""Deleting a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_requires_scope(mock_delete_room):
"""Deleting a room requires the ROOMS_DELETE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_DELETE scope
token = generate_test_token(
user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:delete"
in str(response.data).lower()
)
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_without_ownership(mock_delete_room, role):
"""Only owners should be able to delete a room, administrators included."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users)
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_unknown_room(mock_delete_room):
"""Deleting a room that does not exist should return 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
mock_delete_room.assert_not_called()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_success(mock_delete_room, _, mock_capture):
"""Owners should be able to delete a room: it is soft deleted, its LiveKit
room is closed and a ROOM_DELETED analytics event is emitted."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
)
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.filter(id=room.id).exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_DELETED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_api_rooms_delete_livekit_failure(mock_delete_room, mock_capture):
"""When the LiveKit room can't be closed, the deletion should be rolled back
and no analytics event emitted."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 500
assert response.json() == {"detail": "Could not delete the room, please try again."}
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.get(id=room.id).deleted_at is None
mock_capture.assert_not_called()
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_room_not_live(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""Deleting a room that is not live in LiveKit should still soft delete it,
and delete its SIP dispatch rule, as no room_finished webhook will."""
settings.ROOMKIT_ENABLED = True
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.objects.filter(id=room.id).exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 410
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
mock_delete_room.assert_not_called()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None
+255 -65
View File
@@ -7,17 +7,20 @@ 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 ApplicationScope, User
from core.models import Application, ApplicationScope, User
from core.services import provisional_user_service
pytestmark = pytest.mark.django_db
@@ -28,15 +31,13 @@ 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/",
@@ -55,16 +56,13 @@ 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/",
@@ -95,15 +93,13 @@ 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/",
@@ -170,11 +166,8 @@ 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.client_secret = plain_secret
application.save()
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
client = APIClient()
response = client.post(
@@ -198,15 +191,13 @@ 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/",
@@ -279,14 +270,54 @@ 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.client_secret = plain_secret
application.save()
application = ApplicationFactory(client_secret=plain_secret, is_active=False)
client = APIClient()
response = client.post(
@@ -328,11 +359,8 @@ 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.client_secret = plain_secret
application.save()
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
client = APIClient()
response = client.post(
@@ -353,12 +381,9 @@ 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")
application = ApplicationFactory(is_active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
client = APIClient()
response = client.post(
@@ -379,16 +404,14 @@ 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/",
@@ -407,11 +430,8 @@ 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.client_secret = plain_secret
application.save()
application = ApplicationFactory(client_secret=plain_secret, is_active=True)
client = APIClient()
response = client.post(
@@ -434,15 +454,13 @@ 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/",
@@ -487,15 +505,13 @@ 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/",
@@ -545,15 +561,13 @@ 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/",
@@ -598,12 +612,10 @@ 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
application = ApplicationFactory(
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
application = ApplicationFactory(
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
email = "john.doe@example.com"
@@ -653,12 +665,10 @@ 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
application = ApplicationFactory(
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
application = ApplicationFactory(
client_secret=plain_secret, is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
client = APIClient()
response = client.post(
@@ -674,3 +684,183 @@ 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 check_password
assert check_password(plain_secret, application.client_secret) is True
# Should verify with the application credential policy
assert verify_client_secret(plain_secret, application.client_secret) is True
def test_models_application_client_secret_preserves_existing_hash():
+41 -35
View File
@@ -466,6 +466,11 @@ class Base(Configuration):
"documentation_url": values.Value(
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
),
"technical_documentation_url": values.Value(
None,
environ_name="FRONTEND_TECHNICAL_DOCUMENTATION_URL",
environ_prefix=None,
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
@@ -766,9 +771,6 @@ class Base(Configuration):
ROOM_INACTIVITY_DELETION_DAYS = values.PositiveIntegerValue(
None, environ_name="ROOM_INACTIVITY_DELETION_DAYS", environ_prefix=None
)
ROOM_DELETED_RETENTION_DAYS = values.PositiveIntegerValue(
None, environ_name="ROOM_DELETED_RETENTION_DAYS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
@@ -936,24 +938,18 @@ class Base(Configuration):
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None,
)
MARKETING_SERVICE_CLASS = values.Value(
"core.services.marketing.BrevoMarketingService",
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = SecretFileValue(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=int,
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
LASUITE_MARKETING = {
"BACKEND": values.Value(
"lasuite.marketing.backends.dummy.DummyBackend",
environ_name="LASUITE_MARKETING_BACKEND",
environ_prefix=None,
),
"PARAMETERS": values.DictValue(
default={},
environ_name="LASUITE_MARKETING_PARAMETERS",
environ_prefix=None,
),
}
# Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
@@ -1074,7 +1070,7 @@ class Base(Configuration):
environ_prefix=None,
)
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
128,
50,
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
environ_prefix=None,
)
@@ -1417,30 +1413,39 @@ class Base(Configuration):
stacklevel=2,
)
for setting_name, rooms, period in (
("ROOM_INACTIVITY_DELETION_DAYS", "Inactive rooms", "inactivity period"),
("ROOM_DELETED_RETENTION_DAYS", "Deleted rooms", "retention period"),
):
purge_days = getattr(cls, setting_name)
if not purge_days:
continue
if cls.ROOM_INACTIVITY_DELETION_DAYS:
if not cls.RECORDING_EXPIRATION_DAYS:
warnings.warn(
f"{setting_name} is set but RECORDING_EXPIRATION_DAYS is not. "
f"Recordings never expire, so {rooms.lower()} holding a saved "
"recording will never be purged.",
"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 >= purge_days:
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
warnings.warn(
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
f"{setting_name}. {rooms} holding a saved recording will be "
f"kept past the {period}, until their recordings expire.",
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
"recording will be kept past the inactivity period, until their "
"recordings expire.",
UserWarning,
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,
)
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
@@ -1526,6 +1531,7 @@ class Test(Base):
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.33.0"
version = "1.34.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1297,7 +1297,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.33.0"
version = "1.34.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+4
View File
@@ -39,6 +39,9 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_APP_TITLE
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
ARG VITE_MEDIA_BASE_URL
ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
RUN npm run build
# ---- Front-end image ----
@@ -46,6 +49,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
apk del curl
USER nginx
+313 -38
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.33.0",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.33.0",
"version": "1.34.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
@@ -31,11 +31,11 @@
"livekit-client": "2.21.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.51.0",
"react-aria-components": "1.20.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.12",
"react-stately": "3.48.0",
"react-stately": "3.49.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -64,7 +64,8 @@
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
"vite-plugin-svgr": "5.2.0",
"vitest": "5.0.2"
}
},
"node_modules/@adobe/react-spectrum": {
@@ -883,9 +884,9 @@
}
},
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
"version": "3.12.3",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz",
"integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -901,9 +902,9 @@
}
},
"node_modules/@internationalized/string": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.9.tgz",
"integrity": "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==",
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
"integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -942,9 +943,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -1819,9 +1820,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
"integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -2506,6 +2507,24 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
@@ -2786,6 +2805,64 @@
}
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.2",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
@@ -3151,6 +3228,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4130,6 +4217,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5030,6 +5127,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5532,6 +5636,16 @@
"node": ">=18.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -8647,6 +8761,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -9384,19 +9512,19 @@
}
},
"node_modules/react-aria": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.48.0",
"react-stately": "3.49.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -9405,17 +9533,18 @@
}
},
"node_modules/react-aria-components": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.36.0",
"@internationalized/date": "^3.12.3",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.50.0",
"react-stately": "3.48.0"
"react-aria": "3.51.0",
"react-stately": "3.49.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -9469,15 +9598,15 @@
"license": "MIT"
},
"node_modules/react-stately": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz",
"integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
@@ -10219,6 +10348,13 @@
"node": ">= 0.8"
}
},
"node_modules/std-env": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -10525,6 +10661,26 @@
"xtend": "~4.0.1"
}
},
"node_modules/tinybench": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -11609,6 +11765,112 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.2",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "^6.1.4",
"tinyexec": "^1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^3.2.1"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.2",
"@vitest/browser-preview": "5.0.2",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.2",
"@vitest/coverage-v8": "5.0.2",
"@vitest/ui": "5.0.2",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/walk-sync": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
@@ -11793,6 +12055,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
"dev": true,
"license": "MIT",
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=20.11"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+7 -5
View File
@@ -1,12 +1,13 @@
{
"name": "meet",
"private": true,
"version": "1.33.0",
"version": "1.34.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build",
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
"test": "panda codegen && vitest run",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --fix",
"preview": "vite preview",
@@ -38,11 +39,11 @@
"livekit-client": "2.21.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.51.0",
"react-aria-components": "1.20.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.12",
"react-stately": "3.48.0",
"react-stately": "3.49.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -71,6 +72,7 @@
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
"vite-plugin-svgr": "5.2.0",
"vitest": "5.0.2"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
export const mediaUrl = (path: string) => {
const origin =
import.meta.env.VITE_API_BASE_URL ||
import.meta.env.VITE_MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
// Remove leading/trailing slashes from origin/path if it exists
+2
View File
@@ -22,12 +22,14 @@ export interface ApiConfig {
url: string
}
documentation_url?: string
technical_documentation_url?: string
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
use_french_gov_footer?: boolean
use_proconnect_button?: boolean
allow_unregistered_rooms?: boolean
idle_disconnect_warning_delay?: number
recording?: {
is_enabled?: boolean
@@ -19,7 +19,9 @@ export const LoadingScreen = ({
<Screen layout={layout} header={header} footer={footer}>
<CenteredContent>
<Center>
<p>{t('loading')}</p>
<p role="status" aria-live="polite">
{t('loading')}
</p>
</Center>
</CenteredContent>
</Screen>
@@ -22,6 +22,12 @@ export type IceCandidateInfo = {
port?: number
/** Local candidates only, and not reported by every browser. */
networkType?: string
/**
* For a local relay candidate, the TURN URL it was gathered from
* (e.g. `turns:turn.example.com:443?transport=tcp`). Used as a fallback
* when the browser does not report `relayProtocol`.
*/
url?: string
}
export type IceCandidatePair = {
@@ -42,6 +48,57 @@ export type IceCandidateReport = {
working: IceCandidatePair[]
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
/** Narrows the loosely typed `data` stored on a step result. */
export const isIceCandidateReport = (
data: unknown
): data is IceCandidateReport =>
isObject(data) &&
Array.isArray(data.working) &&
(data.selected === null ||
(isObject(data.selected) && isObject(data.selected.local)))
/**
* Transport between the browser and the TURN server for a local relay
* candidate: udp, tcp or tls, or undefined when it cannot be determined.
*
* `protocol` is deliberately not used here: on a relay candidate it describes
* the TURN allocation (server to peer), which is UDP even when the client
* reaches the TURN server over TLS.
*/
export const getRelayTransport = (
candidate: IceCandidateInfo
): string | undefined => {
if (candidate.relayProtocol) return candidate.relayProtocol.toLowerCase()
if (!candidate.url) return undefined
const url = candidate.url.toLowerCase()
if (url.startsWith('turns:')) return 'tls'
if (!url.startsWith('turn:')) return undefined
const transport = /[?&]transport=(udp|tcp)\b/.exec(url)?.[1]
// RFC 7065: a turn: URI without a transport parameter defaults to UDP.
return transport ?? 'udp'
}
/**
* True when the selected pair goes through a TURN relay reached over TCP or
* TLS. Media still flows, but TCP head-of-line blocking usually degrades
* audio and video under packet loss.
*
* Direct routes (host, srflx, prflx), including ICE-TCP to the SFU, are out of
* scope: the warning and its documentation are about TURN fallbacks.
* An undetermined transport is not evidence of a bad route.
*/
export const isRelayedOverTcp = (data: unknown): boolean => {
if (!isIceCandidateReport(data) || !data.selected) return false
const { local } = data.selected
if (local.type !== 'relay') return false
const transport = getRelayTransport(local)
return transport === 'tcp' || transport === 'tls'
}
const PROBE_WIDTH = 320
const PROBE_HEIGHT = 180
const PROBE_FPS = 15
@@ -57,6 +114,7 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
protocol: stats.protocol as string | undefined,
relayProtocol: stats.relayProtocol as string | undefined,
networkType: stats.networkType as string | undefined,
url: stats.url as string | undefined,
...(INCLUDE_CANDIDATE_ADDRESSES
? {
address: stats.address as string | undefined,
@@ -67,7 +125,10 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
}
const describeCandidate = (candidate: IceCandidateInfo) => {
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
const transport =
(candidate.type === 'relay' ? getRelayTransport(candidate) : undefined) ??
candidate.protocol ??
'unknown'
const endpoint =
candidate.address === undefined
? ''
@@ -2,18 +2,44 @@ import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ProgressBar } from 'react-aria-components'
import { css, cx } from '@/styled-system/css'
import { A } from '@/primitives'
import { useConfig } from '@/api/useConfig'
import type { ConnectionTestStats } from '../types'
import { statusSquareClass } from './stepAppearance'
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
type SummaryState =
| 'idle'
| 'running'
| 'passed'
| 'partial'
| 'failed'
| 'warning'
/** Only a failure earns a colour: everything else stays near-black. */
/** Only a failure or a degraded route earns a colour: everything else stays near-black. */
const stateColorClass: Record<SummaryState, string> = {
idle: css({ color: 'greyscale.1000' }),
running: css({ color: 'greyscale.1000' }),
passed: css({ color: 'greyscale.1000' }),
partial: css({ color: 'greyscale.1000' }),
failed: css({ color: 'danger.600' }),
warning: css({ color: 'warning' }),
}
/**
* A hard failure still outranks a warning step; a warning outranks 'partial'
* because a measured degraded route matters more than skipped camera or
* microphone checks.
*/
const getSummaryState = (
stats: ConnectionTestStats,
isRunning: boolean
): SummaryState => {
if (isRunning) return 'running'
if (!stats.hasStarted) return 'idle'
if (stats.failed > 0) return 'failed'
if (stats.warnings > 0) return 'warning'
if (stats.skipped > 0) return 'partial'
return 'passed'
}
const cardClass = css({
@@ -183,16 +209,15 @@ export const ConnectionTestSummary = ({
children?: ReactNode
}) => {
const { t } = useTranslation('connectionTest')
const { data: config } = useConfig()
const state: SummaryState = isRunning
? 'running'
: !stats.hasStarted
? 'idle'
: stats.failed > 0
? 'failed'
: stats.skipped > 0
? 'partial'
: 'passed'
// Network prerequisites for the reader's IT department. Instance specific,
// so it comes from the backend; without it the warning shows no link.
const networkDocUrl = config?.technical_documentation_url
const state = getSummaryState(stats, isRunning)
// Skipped device checks still deserve their hint under a route warning.
const showPartialHint = state === 'warning' && stats.skipped > 0
return (
<section className={cardClass}>
@@ -206,7 +231,27 @@ export const ConnectionTestSummary = ({
: t(`summary.${state}`)}
</p>
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
<p className={hintClass}>
{t(`summary.${state}Hint`)}
{state === 'warning' && networkDocUrl && (
<>
{' '}
<A
href={networkDocUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
externalIcon
aria-label={t('summary.warningDocLinkAriaLabel')}
>
{t('summary.warningDocLink')}
</A>
</>
)}
</p>
{showPartialHint && (
<p className={hintClass}>{t('summary.partialHint')}</p>
)}
</div>
{stats.hasStarted && (
@@ -237,6 +282,13 @@ export const ConnectionTestSummary = ({
value={stats.passed}
label={t('counts.passed')}
/>
{stats.warnings > 0 && (
<Counter
squareClass={statusSquareClass.warning}
value={stats.warnings}
label={t('counts.warnings')}
/>
)}
<Counter
squareClass={statusSquareClass.skipped}
value={stats.skipped}
@@ -15,15 +15,20 @@ export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
animation: 'pulse_background 1.2s ease-in-out infinite',
}),
success: css({ backgroundColor: 'success.600' }),
warning: css({ backgroundColor: 'warning' }),
failed: css({ backgroundColor: 'danger.600' }),
skipped: css({ backgroundColor: 'greyscale.300' }),
}
/** Colour is carried by the square; the label stays near-black except on failure. */
/**
* Colour is carried by the square; the label stays near-black except on
* failure and warning.
*/
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
pending: css({ color: 'greyscale.500' }),
running: css({ color: 'greyscale.700' }),
success: css({ color: 'greyscale.1000' }),
warning: css({ color: 'warning', fontWeight: 'medium' }),
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
skipped: css({ color: 'greyscale.500' }),
}
@@ -8,7 +8,10 @@ import {
type CheckInfo,
} from 'livekit-client'
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
import {
isRelayedOverTcp,
SelectedCandidateCheck,
} from '../checks/selectedCandidate'
import {
createInitialSteps,
type ConnectionTestLog,
@@ -49,10 +52,23 @@ const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
const isPermissionError = (error: unknown) =>
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
const toStepStatus = (info: CheckInfo): ConnectionTestStepStatus => {
const status = CHECK_STATUS_TO_STEP[info.status] ?? 'failed'
return status === 'success' && isRelayedOverTcp(info.data)
? 'warning'
: status
}
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
status: toStepStatus(info),
summary: info.description,
logs: info.logs,
// Only SelectedCandidateCheck sets `data` (the ICE candidate report).
// Consumers narrow it with a type guard (see isIceCandidateReport).
data:
typeof info.data === 'object' && info.data !== null
? (info.data as Record<string, unknown>)
: undefined,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
@@ -15,6 +15,7 @@ export type ConnectionTestStepStatus =
| 'pending'
| 'running'
| 'success'
| 'warning'
| 'failed'
| 'skipped'
@@ -63,6 +64,7 @@ export type ConnectionTestStats = {
total: number
settled: number
passed: number
warnings: number
failed: number
skipped: number
hasStarted: boolean
@@ -77,24 +79,27 @@ export const summarizeSteps = (
steps: ConnectionTestStepResult[]
): ConnectionTestStats => {
let passed = 0
let warnings = 0
let failed = 0
let skipped = 0
let pending = 0
for (const step of steps) {
if (step.status === 'success') passed += 1
else if (step.status === 'warning') warnings += 1
else if (step.status === 'failed') failed += 1
else if (step.status === 'skipped') skipped += 1
else if (step.status === 'pending') pending += 1
}
const total = steps.length
const settled = passed + failed + skipped
const settled = passed + warnings + failed + skipped
return {
total,
settled,
passed,
warnings,
failed,
skipped,
hasStarted: pending < total,
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { generateRoomId } from '@/features/rooms'
export const CreateUnregisteredMeetingButton = () => {
const { t } = useTranslation('home')
return (
<Button
variant="primary"
data-attr="create-unregistered-meeting"
onPress={() =>
navigateTo('room', generateRoomId(), { state: { create: true } })
}
>
{t('createMeeting')}
</Button>
)
}
@@ -9,6 +9,7 @@ import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { IntroSlider } from '../components/IntroSlider'
import { MoreLink } from '../components/MoreLink'
import { CreateMeetingMenu } from '../components/CreateMeetingMenu'
import { CreateUnregisteredMeetingButton } from '../components/CreateUnregisteredMeetingButton'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
@@ -189,13 +190,19 @@ const Home = () => {
display: 'flex',
gap: 0.5,
flexDirection: { base: 'column', xsm: 'row' },
flexWrap: 'wrap',
alignItems: { base: 'center', xsm: 'items-start' },
})}
>
{isLoggedIn ? (
<CreateMeetingMenu />
) : (
<LoginButton proConnectHint={false} />
<>
{data?.allow_unregistered_rooms && (
<CreateUnregisteredMeetingButton />
)}
<LoginButton proConnectHint={false} />
</>
)}
<DialogTrigger>
<Button
@@ -7,7 +7,6 @@ import {
useSwipe,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
@@ -62,13 +61,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
>
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > layout.maxTiles && (
<>
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
/>
<PaginationControl pagesContainer={gridEl} {...pagination} />
</>
<PaginationControl {...pagination} focusShortcut />
)}
</div>
)
@@ -1,99 +1,147 @@
import * as React from 'react'
import { createInteractingObservable } from '@livekit/components-core'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { css, cva, type RecipeVariantProps } from '@/styled-system/css'
import { useCallback, useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export interface PaginationControlProps {
const paginationToolbar = cva({
base: {
display: 'flex',
alignItems: 'center',
gap: '0.125rem',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
border: '1px solid',
borderColor: 'primaryDark.200',
padding: '0.375rem',
},
variants: {
placement: {
overlay: {
position: 'absolute',
bottom: '1rem',
left: '50%',
zIndex: 2,
transform: 'translateX(-50%)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.45)',
},
inline: {
alignSelf: 'center',
flexShrink: 0,
marginTop: '1rem',
},
},
},
defaultVariants: {
placement: 'overlay',
},
})
export type PaginationControlProps = RecipeVariantProps<
typeof paginationToolbar
> & {
totalPageCount: number
nextPage: () => void
prevPage: () => void
currentPage: number
pagesContainer?: React.RefObject<HTMLElement>
// The shortcut listens on the main window only, so a single instance may own it.
focusShortcut?: boolean
}
const arrowButtonClass = css({
_disabled: {
cursor: 'default',
backgroundColor: 'transparent !important',
'& svg': {
opacity: 0.35,
},
_focusVisible: {
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: '2px',
},
},
})
export function PaginationControl({
totalPageCount,
nextPage,
prevPage,
currentPage,
pagesContainer: connectedElement,
placement,
focusShortcut = false,
}: PaginationControlProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
const [interactive, setInteractive] = useState(false)
const prevButtonRef = useRef<HTMLButtonElement>(null)
const nextButtonRef = useRef<HTMLButtonElement>(null)
const isSinglePage = totalPageCount <= 1
const isFirstPage = currentPage <= 1
const isLastPage = currentPage >= totalPageCount
useEffect(() => {
let subscription:
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
| undefined
if (connectedElement) {
subscription = createInteractingObservable(
connectedElement.current,
2000
).subscribe(setInteractive)
}
return () => {
if (subscription) {
subscription.unsubscribe()
}
}
}, [connectedElement])
const focusPagination = useCallback(() => {
const target = isLastPage ? prevButtonRef.current : nextButtonRef.current
target?.focus()
}, [isLastPage])
if (totalPageCount <= 1) return null
useRegisterKeyboardShortcut({
id: focusShortcut ? 'focus-pagination' : undefined,
handler: focusPagination,
isDisabled: isSinglePage,
unregisterOnUnmount: true,
})
if (isSinglePage) return null
const pageCount = t('count', { currentPage, totalPageCount })
return (
<nav
aria-label={t('label')}
className={css({
position: 'absolute',
bottom: '1rem',
left: '50%',
transform: 'translateX(-50%)',
alignItems: 'stretch',
backgroundColor: 'var(--lk-control-bg)',
borderRadius: 'var(--lk-border-radius)',
transition: 'opacity ease-in-out .15s',
display: 'none',
border: '1px solid',
borderColor: 'primaryDark.100',
overflow: 'hidden',
})}
style={{
display: interactive ? 'flex' : 'none',
}}
data-lk-user-interaction={interactive}
<div
role="group"
aria-label={`${t('label')}, ${pageCount}`}
className={paginationToolbar({ placement })}
>
<Button
isDisabled={currentPage == 1}
onPress={prevPage}
size="xs"
variant="quaternaryText"
ref={prevButtonRef}
aria-disabled={isFirstPage}
onPress={isFirstPage ? undefined : prevPage}
size="sm"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('previous')}
tooltip={t('previous')}
>
<RiArrowLeftSLine />
<RiArrowLeftSLine size={20} />
</Button>
<span
role="status"
className={css({
padding: '0.25rem 0.5rem',
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.5rem',
textAlign: 'center',
userSelect: 'none',
padding: '0 0.35rem',
whiteSpace: 'nowrap',
})}
>
{t('count', {
currentPage,
totalPageCount,
})}
{pageCount}
</span>
<Button
isDisabled={currentPage == totalPageCount}
onPress={nextPage}
size="xs"
variant="quaternaryText"
ref={nextButtonRef}
aria-disabled={isLastPage}
onPress={isLastPage ? undefined : nextPage}
size="sm"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('next')}
tooltip={t('next')}
>
<RiArrowRightSLine />
<RiArrowRightSLine size={20} />
</Button>
</nav>
</div>
)
}
@@ -1,30 +0,0 @@
import * as React from 'react'
export interface PaginationIndicatorProps {
totalPageCount: number
currentPage: number
}
export const PaginationIndicator: (
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
PaginationIndicatorProps
>(function PaginationIndicator(
{ totalPageCount, currentPage }: PaginationIndicatorProps,
ref
) {
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
if (index + 1 === currentPage) {
return <span data-lk-active key={index} />
} else {
return <span key={index} />
}
})
return (
<div ref={ref} className="lk-pagination-indicator" aria-hidden="true">
{bubbles}
</div>
)
})
@@ -1,96 +0,0 @@
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
interface PipPaginationProps {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
export const PipPagination = ({
totalPageCount,
currentPage,
nextPage,
prevPage,
}: PipPaginationProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
if (totalPageCount <= 1) return null
return (
<Nav aria-label={t('label')}>
<ArrowButton
type="button"
onClick={prevPage}
disabled={currentPage === 1}
aria-label={t('previous')}
>
<RiArrowLeftSLine size={18} />
</ArrowButton>
<Counter role="status">
{t('count', { currentPage, totalPageCount })}
</Counter>
<ArrowButton
type="button"
onClick={nextPage}
disabled={currentPage === totalPageCount}
aria-label={t('next')}
>
<RiArrowRightSLine size={18} />
</ArrowButton>
</Nav>
)
}
const Nav = styled('nav', {
base: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
marginTop: '1rem',
flexShrink: 0,
},
})
const ArrowButton = styled('button', {
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1.75rem',
height: '1.75rem',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
color: 'white',
backgroundColor: 'primaryDark.100',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover:not(:disabled)': {
backgroundColor: 'primaryDark.75',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'white',
outlineOffset: '2px',
},
'&:disabled': {
opacity: 0.3,
cursor: 'default',
},
},
})
const Counter = styled('span', {
base: {
fontSize: '0.75rem',
color: 'white',
opacity: 0.8,
whiteSpace: 'nowrap',
padding: '0 0.25rem',
minWidth: '3rem',
textAlign: 'center',
},
})
@@ -4,7 +4,7 @@ import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { PaginationControl } from '@/features/layout/components/PaginationControl'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
@@ -120,12 +120,7 @@ const PaginatedStage = ({
}) => (
<StageWrapper>
<StageFrame>{children}</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
<PaginationControl {...pagination} placement="inline" />
</StageWrapper>
)
@@ -12,8 +12,6 @@ export enum ApiLobbyStatus {
DENIED = 'denied',
TIMEOUT = 'timeout',
ACCEPTED = 'accepted',
// Client-side only: the room was deleted while waiting
DELETED = 'deleted',
}
export interface ApiRequestEntry {
@@ -85,7 +85,6 @@ export const Conference = ({
const {
status: fetchStatus,
isError: isFetchError,
error: fetchError,
data,
} = useQuery({
queryKey: fetchKey,
@@ -99,8 +98,6 @@ export const Conference = ({
if (error.statusCode == '404') {
createRoom({ slug: roomId, username })
}
// A deleted room can't be recreated, surface the error instead
if (error.statusCode == '410') throw error
}),
retry: false,
})
@@ -201,15 +198,6 @@ export const Conference = ({
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (fetchError?.statusCode == 410) {
return (
<ErrorScreen
title={t('error.deletedRoom.heading')}
body={t('error.deletedRoom.body')}
/>
)
}
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return (
@@ -294,7 +282,6 @@ export const Conference = ({
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
case DisconnectReason.ROOM_DELETED:
navigateTo(
'feedback',
{},
@@ -40,11 +40,15 @@ const StyledRACDialog = styled(Dialog, {
})
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const isCreatingUnregisteredRoom =
roomData?.id === null && !!history.state?.create
const [isDismissed, setIsDismissed] = useState(false)
const showInviteDialog =
!isDismissed && (mode === 'create' || isCreatingUnregisteredRoom)
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const telephony = useTelephony()
@@ -78,7 +82,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
variant="tertiaryText"
size="xs"
onPress={() => {
setShowInviteDialog(false)
setIsDismissed(true)
}}
aria-label={t('closeDialog')}
>
@@ -74,9 +74,7 @@ export const Lobby = ({
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data, error } = await refetchRoom()
if (error?.statusCode == 410) return
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
@@ -90,22 +88,7 @@ export const Lobby = ({
enterRoom()
}
const isRoomDeleted = isError && error?.statusCode == 410
const lobbyStatus = isRoomDeleted ? ApiLobbyStatus.DELETED : status
switch (lobbyStatus) {
case ApiLobbyStatus.DELETED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('deleted.title')}
</H>
<Text as="p" variant="note">
{t('deleted.body')}
</Text>
</VStack>
)
switch (status) {
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { ApiError } from '@/api/ApiError'
import {
requestEntry,
ApiLobbyStatus,
@@ -40,21 +39,10 @@ export const useLobby = ({
const { data: waitingData } = useQuery({
queryKey: [keys.requestEntry, roomId],
queryFn: async () => {
let response: ApiRequestEntry
try {
response = await requestEntry({
roomId,
username,
})
} catch (error) {
// The room was deleted while waiting, stop polling
if (error instanceof ApiError && error.statusCode === 410) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.DELETED)
return { status: ApiLobbyStatus.DELETED }
}
throw error
}
const response = await requestEntry({
roomId,
username,
})
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
@@ -0,0 +1,43 @@
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
const SPEAKING_DETECTION_DELAY = 3000
/**
* Offers to lower the local participant's raised hand after
* SPEAKING_DETECTION_DELAY of speaking. Mount it once: each copy runs its own
* timer and shows its own toast.
*/
export const LowerHandOnSpeaking = () => {
const room = useRoomContext()
const { isHandRaised, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const [hasOffered, setHasOffered] = useState(false)
useEffect(() => {
if (isHandRaised) return
setHasOffered(false)
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
if (!isSpeaking || !isHandRaised || hasOffered) return
const timer = setTimeout(() => {
setHasOffered(true)
showLowerHandToast(room.localParticipant, lowerHand)
}, SPEAKING_DETECTION_DELAY)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasOffered])
return null
}
@@ -2,19 +2,12 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
@@ -25,64 +18,15 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
const resetToastState = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: handleToggle,
handler: toggleRaisedHand,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
lowerHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
}, SPEAKING_DETECTION_DELAY)
}
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
@@ -100,7 +44,7 @@ export const HandToggle = ({
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={(e) => {
handleToggle()
toggleRaisedHand()
onPress?.(e)
}}
data-attr={`controls-hand-${tooltipLabel}`}
@@ -31,35 +31,20 @@ export const useSidePanel = () => {
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
// Reads the live store, not the render snapshot: shortcut handlers outlive
// toggles that unmount (e.g. inside a closed overflow menu).
const togglePanel = (panelId: PanelId) => {
layoutStore.activePanelId =
layoutStore.activePanelId === panelId ? null : panelId
if (layoutStore.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleAdmin = () => togglePanel(PanelId.ADMIN)
const toggleParticipants = () => togglePanel(PanelId.PARTICIPANTS)
const toggleChat = () => togglePanel(PanelId.CHAT)
const toggleEffects = () => togglePanel(PanelId.EFFECTS)
const toggleTools = () => togglePanel(PanelId.TOOLS)
const toggleInfo = () => togglePanel(PanelId.INFO)
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
@@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LowerHandOnSpeaking } from '@/features/rooms/livekit/components/LowerHandOnSpeaking'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/**
@@ -119,6 +120,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<LowerHandOnSpeaking />
<MediaStateObserver />
<ChatProvider />
<LobbyProvider />
@@ -31,7 +31,6 @@ const buttonClass = css({
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
RoomDeleted = 'roomDeleted',
}
const FeedbackRoute = () => {
@@ -47,8 +46,6 @@ const FeedbackRoute = () => {
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
case DisconnectReason.ROOM_DELETED:
return DisconnectReasonKey.RoomDeleted
}
}, [])
@@ -59,10 +56,7 @@ const FeedbackRoute = () => {
}
}, [])
// Rejoining is not possible once removed or once the room is deleted
const showBackButton =
reasonKey !== DisconnectReasonKey.ParticipantRemoved &&
reasonKey !== DisconnectReasonKey.RoomDeleted
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
return (
<Screen layout="centered" footer={false}>
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { normalizeRoomId } from './isRoomValid'
describe('normalizeRoomId', () => {
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
expect(normalizeRoomId('ABCDEFGHIJ')).toBe('abc-defg-hij')
expect(normalizeRoomId('abc-defghij')).toBe('abc-defg-hij')
})
it('returns any other input unchanged', () => {
expect(normalizeRoomId('abc-def')).toBe('abc-def')
expect(normalizeRoomId('Not-A-Room')).toBe('Not-A-Room')
})
})
@@ -7,6 +7,7 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'focus-pagination'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
@@ -44,6 +45,11 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'navigation',
shortcut: { key: 'F2' },
},
{
id: 'focus-pagination',
category: 'navigation',
shortcut: { key: 'G', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-microphone',
category: 'media',
@@ -7,12 +7,16 @@ export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
// Opt-in: controls rendered inside menus unmount when the menu closes, and
// their shortcuts must keep working.
unregisterOnUnmount?: boolean
}
export const useRegisterKeyboardShortcut = ({
id,
handler,
isDisabled = false,
unregisterOnUnmount = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!id) return
@@ -21,8 +25,14 @@ export const useRegisterKeyboardShortcut = ({
const formattedKey = formatShortcutKey(descriptor.shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
return
}
}, [handler, id, isDisabled])
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
if (!unregisterOnUnmount) return
return () => {
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
}
}
}, [handler, id, isDisabled, unregisterOnUnmount])
}
+23 -16
View File
@@ -126,6 +126,9 @@ export const Footer = () => {
return null
}
const isConnectionTestEnabled = !!data.diagnostics?.connection_test_enabled
const technicalDocumentationUrl = data.technical_documentation_url
return (
<footer
className={css({
@@ -256,7 +259,9 @@ export const Footer = () => {
{t('links.data')}
</A>
</StyledLi>
<StyledLi divider>
<StyledLi
divider={isConnectionTestEnabled || !!technicalDocumentationUrl}
>
<Link
underline={false}
footer="minor"
@@ -266,8 +271,8 @@ export const Footer = () => {
{t('links.accessibility')}
</Link>
</StyledLi>
{data?.diagnostics?.connection_test_enabled && (
<StyledLi divider>
{isConnectionTestEnabled && (
<StyledLi divider={!!technicalDocumentationUrl}>
<Link
underline={false}
footer="minor"
@@ -278,19 +283,21 @@ export const Footer = () => {
</Link>
</StyledLi>
)}
<StyledLi>
<A
externalIcon
underline={false}
footer="minor"
href="https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
aria-label={
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
}
>
{t('links.technicalDetails')}
</A>
</StyledLi>
{technicalDocumentationUrl && (
<StyledLi>
<A
externalIcon
underline={false}
footer="minor"
href={technicalDocumentationUrl}
aria-label={
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
}
>
{t('links.technicalDetails')}
</A>
</StyledLi>
)}
</SecondRow>
<ThirdRow>
{t('mentions')}{' '}
@@ -29,11 +29,13 @@
"pending": "Ausstehend",
"running": "Läuft…",
"success": "Erfolgreich",
"warning": "Nicht optimal",
"failed": "Fehlgeschlagen",
"skipped": "Übersprungen"
},
"counts": {
"passed": "erfolgreich",
"warnings": "nicht optimal",
"failed": "fehlgeschlagen",
"skipped": "übersprungen"
},
@@ -46,6 +48,10 @@
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
"partial": "Teilweiser Test",
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
"warning": "Verbindung nicht optimal",
"warningHint": "Sie können an Ihren Besprechungen teilnehmen, aber die Bild- und Tonqualität kann aufgrund Ihrer Netzwerkeinstellungen beeinträchtigt sein. Ihre IT-Abteilung kann hier Abhilfe schaffen.",
"warningDocLink": "Netzwerkanforderungen für Ihre IT-Abteilung",
"warningDocLinkAriaLabel": "Netzwerkanforderungen für Ihre IT-Abteilung öffnen – öffnet in neuem Tab",
"failed_one": "{{count}} Prüfung fehlgeschlagen",
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
+2 -10
View File
@@ -3,8 +3,7 @@
"heading": {
"normal": "Du hast das Meeting verlassen",
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt",
"roomDeleted": "Dieses Meeting wurde gelöscht"
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -95,10 +94,6 @@
"timeoutInvite": {
"title": "Du kannst diesem Meeting nicht beitreten",
"body": "Niemand hat auf deine Anfrage reagiert"
},
"deleted": {
"title": "Du kannst diesem Meeting nicht beitreten",
"body": "Dieses Meeting wurde gelöscht."
}
},
"leaveRoomPrompt": "Hiermit verlässt du das Meeting.",
@@ -220,10 +215,6 @@
"heading": "Authentifizierung erforderlich",
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifiziere dich, um ihn zu erstellen, oder warte, bis eine authentifizierte Person dies tut."
},
"deletedRoom": {
"heading": "Meeting gelöscht",
"body": "Dieses Meeting wurde gelöscht und kann nicht mehr betreten werden."
},
"screenShare": {
"title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich",
@@ -793,6 +784,7 @@
"actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste",
"focus-pagination": "Fokus auf die Teilnehmerseiten",
"toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk",
@@ -29,11 +29,13 @@
"pending": "Pending",
"running": "Running…",
"success": "Passed",
"warning": "Not optimal",
"failed": "Failed",
"skipped": "Skipped"
},
"counts": {
"passed": "passed",
"warnings": "not optimal",
"failed": "failed",
"skipped": "skipped"
},
@@ -46,6 +48,10 @@
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
"partial": "Partially tested",
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
"warning": "Suboptimal connection",
"warningHint": "You can join your meetings, but video and audio quality may be reduced because of your network settings. Your IT department can improve this.",
"warningDocLink": "Network requirements for your IT department",
"warningDocLinkAriaLabel": "Open the network requirements for your IT department - opens in new window",
"failed_one": "{{count}} check failed",
"failed_other": "{{count}} checks failed",
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
+2 -10
View File
@@ -3,8 +3,7 @@
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by a host",
"roomDeleted": "This meeting has been deleted"
"participantRemoved": "You have been removed from the meeting by a host"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -95,10 +94,6 @@
"timeoutInvite": {
"title": "You cannot join this call",
"body": "No one responded to your request"
},
"deleted": {
"title": "You cannot join this call",
"body": "This meeting has been deleted."
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
@@ -220,10 +215,6 @@
"heading": "Authentication Required",
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
},
"deletedRoom": {
"heading": "Meeting deleted",
"body": "This meeting has been deleted and can no longer be joined."
},
"screenShare": {
"title": "Unable to share your screen",
"ariaLabel": "Unable to share your screen",
@@ -793,6 +784,7 @@
"actions": {
"open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar",
"focus-pagination": "Focus participant pagination",
"toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)",
@@ -29,11 +29,13 @@
"pending": "En espera",
"running": "En curso…",
"success": "Correcto",
"warning": "No óptimo",
"failed": "Error",
"skipped": "Omitido"
},
"counts": {
"passed": "correctas",
"warnings": "no óptimas",
"failed": "con errores",
"skipped": "omitidas"
},
@@ -46,6 +48,10 @@
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
"partial": "Prueba parcial",
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
"warning": "Conexión no óptima",
"warningHint": "Puedes participar en tus reuniones, pero la calidad de la imagen y del sonido puede verse reducida por la configuración de tu red. Tu servicio informático puede mejorar la situación.",
"warningDocLink": "Requisitos de red para tu servicio informático",
"warningDocLinkAriaLabel": "Abrir los requisitos de red para tu servicio informático - se abre en una nueva ventana",
"failed_one": "{{count}} verificación en error",
"failed_other": "{{count}} verificaciones en error",
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
+2 -10
View File
@@ -3,8 +3,7 @@
"heading": {
"normal": "Has salido de la reunión",
"duplicateIdentity": "Te has unido a la reunión desde otro dispositivo",
"participantRemoved": "Un administrador te ha expulsado de la llamada",
"roomDeleted": "Esta reunión ha sido eliminada"
"participantRemoved": "Un administrador te ha expulsado de la llamada"
},
"home": "Volver al inicio",
"back": "Volver a la reunión"
@@ -95,10 +94,6 @@
"timeoutInvite": {
"title": "No puedes participar en esta llamada",
"body": "Nadie ha respondido a tu solicitud de participación en la llamada"
},
"deleted": {
"title": "No puedes participar en esta llamada",
"body": "Esta reunión ha sido eliminada."
}
},
"leaveRoomPrompt": "Volver al inicio hará que salgas de la reunión.",
@@ -220,10 +215,6 @@
"heading": "Autenticación necesaria",
"body": "Esta reunión todavía no se ha creado. Autentícate para crearla o espera a que lo haga un usuario autenticado."
},
"deletedRoom": {
"heading": "Reunión eliminada",
"body": "Esta reunión ha sido eliminada y ya no es posible unirse a ella."
},
"screenShare": {
"title": "No se puede compartir tu pantalla",
"ariaLabel": "No se puede compartir tu pantalla",
@@ -792,6 +783,7 @@
"actions": {
"open-shortcuts": "Abrir la ayuda de atajos",
"focus-toolbar": "Poner el foco en la barra de herramientas inferior",
"focus-pagination": "Poner el foco en la paginación de participantes",
"toggle-microphone": "Activar o desactivar el micrófono",
"toggle-camera": "Activar o desactivar la cámara",
"push-to-talk": "Pulsar para hablar (mantener para reactivar)",
@@ -29,11 +29,13 @@
"pending": "En attente",
"running": "En cours…",
"success": "Réussi",
"warning": "Non optimal",
"failed": "Échec",
"skipped": "Ignoré"
},
"counts": {
"passed": "réussis",
"warnings": "non optimaux",
"failed": "en échec",
"skipped": "ignorés"
},
@@ -46,6 +48,10 @@
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
"partial": "Test partiel",
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
"warning": "Connexion non optimale",
"warningHint": "Vous pouvez participer à vos réunions, mais la qualité de l'image et du son risque d'être réduite à cause des réglages de votre réseau. Votre service informatique peut améliorer la situation.",
"warningDocLink": "Prérequis réseau à transmettre à votre service informatique",
"warningDocLinkAriaLabel": "Ouvrir les prérequis réseau à transmettre à votre service informatique - ouvre dans une nouvelle fenêtre",
"failed_one": "{{count}} vérification en échec",
"failed_other": "{{count}} vérifications en échec",
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
+2 -10
View File
@@ -3,8 +3,7 @@
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur",
"roomDeleted": "Cette réunion a été supprimée"
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
@@ -95,10 +94,6 @@
"timeoutInvite": {
"title": "Vous ne pouvez pas participer à cet appel",
"body": "Personne n'a répondu à votre demande de participation à l'appel"
},
"deleted": {
"title": "Vous ne pouvez pas participer à cet appel",
"body": "Cette réunion a été supprimée."
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
@@ -220,10 +215,6 @@
"heading": "Authentification requise",
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
},
"deletedRoom": {
"heading": "Réunion supprimée",
"body": "Cette réunion a été supprimée et n'est plus accessible."
},
"screenShare": {
"title": "Impossible de partager votre écran",
"ariaLabel": "Impossible de partager votre écran",
@@ -793,6 +784,7 @@
"actions": {
"open-shortcuts": "Ouvrir l’aide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
"focus-pagination": "Mettre le focus sur la pagination des participants",
"toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
@@ -29,11 +29,13 @@
"pending": "In afwachting",
"running": "Bezig…",
"success": "Geslaagd",
"warning": "Niet optimaal",
"failed": "Mislukt",
"skipped": "Overgeslagen"
},
"counts": {
"passed": "geslaagd",
"warnings": "niet optimaal",
"failed": "mislukt",
"skipped": "overgeslagen"
},
@@ -46,6 +48,10 @@
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
"partial": "Gedeeltelijke test",
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
"warning": "Verbinding niet optimaal",
"warningHint": "Je kunt deelnemen aan je vergaderingen, maar de beeld- en geluidskwaliteit kan minder zijn door de instellingen van je netwerk. Je IT-afdeling kan dit verbeteren.",
"warningDocLink": "Netwerkvereisten voor je IT-afdeling",
"warningDocLinkAriaLabel": "Netwerkvereisten voor je IT-afdeling openen - opent in nieuw venster",
"failed_one": "{{count}} controle mislukt",
"failed_other": "{{count}} controles mislukt",
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
+2 -10
View File
@@ -3,8 +3,7 @@
"heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd",
"roomDeleted": "Deze vergadering is verwijderd"
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
@@ -95,10 +94,6 @@
"timeoutInvite": {
"title": "U kunt niet deelnemen aan dit gesprek",
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
},
"deleted": {
"title": "U kunt niet deelnemen aan dit gesprek",
"body": "Deze vergadering is verwijderd."
}
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
@@ -220,10 +215,6 @@
"heading": "Verificatie vereist",
"body": "Deze ruimte is nog niet gemaakt. Logt u alstublieft in om hem aan te maken, of wacht tot een ingelogde gebruiker dat doet."
},
"deletedRoom": {
"heading": "Vergadering verwijderd",
"body": "Deze vergadering is verwijderd en u kunt er niet meer aan deelnemen."
},
"screenShare": {
"title": "Kan uw scherm niet delen",
"ariaLabel": "Kan uw scherm niet delen",
@@ -793,6 +784,7 @@
"actions": {
"open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk",
"focus-pagination": "Focus op de paginering van deelnemers",
"toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
+5
View File
@@ -103,3 +103,8 @@ html:has(.lk-video-conference) {
opacity: 1;
pointer-events: auto;
}
/* Same workaround as above, see adobe/react-spectrum#10680 */
[role='tooltip'][data-rac]:not([data-placement]) {
visibility: hidden;
}
+1
View File
@@ -6,6 +6,7 @@ declare const __MEDIAPIPE_VERSION__: string
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
readonly VITE_MEDIA_BASE_URL?: string
}
interface ImportMeta {
+1 -1
View File
@@ -10,5 +10,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vitest.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
// Kept apart from vite.config.ts so the tests load none of the build plugins.
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
})
+26 -16
View File
@@ -24,7 +24,7 @@ _summaryEnvVars: &summaryEnvVars
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: http://dev-backend-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
@@ -69,9 +69,9 @@ _summaryEnvVars: &summaryEnvVars
LLM_MODEL: Qwen/Qwen3-Coder-30B-A3B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
CELERY_BROKER_URL: redis://user:pass@dev-backend-redis:6379/1
CELERY_RESULT_BACKEND: redis://user:pass@dev-backend-redis:6379/1
TASK_TRACKER_REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED: true
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP: 0.5
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS: true
@@ -127,12 +127,21 @@ backend:
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
# Databases
DB_HOST: postgres
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_HOST: dev-backend-postgres
DB_NAME:
secretKeyRef:
name: dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: dev-backend-postgres
key: password
DB_PORT: 5432
REDIS_URL: redis://default:pass@redis-master:6379/1
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
# Static files
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
# Permissions
@@ -157,12 +166,13 @@ backend:
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
FRONTEND_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/7c5bd65d-3c21-486f-bce1-26e0a921d642/"
FRONTEND_TECHNICAL_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
FRONTEND_IS_SILENT_LOGIN_ENABLED: False
# S3 Storage
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000
AWS_S3_ENDPOINT_URL: http://dev-backend-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
@@ -185,7 +195,7 @@ backend:
MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io
FILE_UPLOAD_ENABLED: True
CELERY_ENABLED: True
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_BROKER_URL: redis://user:pass@dev-backend-redis:6379/1
# Recording & Transcription
RECORDING_ENABLE: True
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
@@ -265,11 +275,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: garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/upstream-vhost: dev-backend-garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: garage.meet.svc.cluster.local
host: dev-backend-garage.meet.svc.cluster.local
port: 9000
# ---- Extra ingress/service for background file uploads ------------
@@ -281,11 +291,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: garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/upstream-vhost: dev-backend-garage.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/files/$1
serviceMediaFiles:
host: garage.meet.svc.cluster.local
host: dev-backend-garage.meet.svc.cluster.local
port: 9000
# ---- STT Orchestration Microservice Components --------------------
@@ -363,7 +373,7 @@ agentMetadata:
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
AWS_S3_ENDPOINT_URL: garage.meet.svc.cluster.local:9000
AWS_S3_ENDPOINT_URL: dev-backend-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

Some files were not shown because too many files have changed in this diff Show More