Compare commits

...

77 Commits

Author SHA1 Message Date
lebaudantoine 10dd22642c ⚡️(backend) send recording notification emails from a Celery task
Emails were rendered and sent inline in the LiveKit webhook, so a
slow SMTP server blocked the request and a transient failure lost
the email. Delivery now runs in a worker, with retries and backoff.

The task is generic (template, subject, recipients, context,
language) so other emails can reuse it. The subject is translated
in the worker, and dates are formatted per owner before queuing,
so the arguments stay JSON-serializable.

Without Celery, the task still runs synchronously. Success now
means "queued", not "delivered".
2026-10-10 00:55:47 +02:00
lebaudantoine 0d79e6418a 📝(doc) update the CHANGELOG
Update the CHANGELOG with the entries for the recent
changes landed on the project.
2026-10-09 17:37:15 +02:00
lebaudantoine 811540c85f 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
Trivy flags tiff 4.7.1-r0 (HIGH) in the nginx alpine 3.24 base
of the frontend images. Force the upgrade to 4.7.2-r0.
2026-10-09 17:37:15 +02:00
lebaudantoine cee2109726 🐛(backend) use the configured token type in BaseJWTAuthentication
authenticate and authenticate_header hardcoded "Bearer" instead of
using the token type the backend is configured with. Setting
APPLICATION_JWT_TOKEN_TYPE, ADDONS_TOKEN_TYPE or USER_ACCESS_TOKEN_TYPE
to anything else made every token be ignored, since the Authorization
scheme never matched.

Store the token type on the backend and use it both to match the
Authorization header scheme (case-insensitively) and as the
WWW-Authenticate scheme.
2026-10-09 17:37:15 +02:00
lebaudantoine 7fa9c3fa4f ♻️(backend) share the user access token test helper
generate_user_access_token was copy-pasted in seven test modules
(user access token authentication, and rooms create, list, retrieve,
update, participants management, rename/toggle and subtitle).

Extract it into core.tests.utils and reuse it everywhere. The shared
version keeps the optional application and claim overrides from the
authentication tests, and reads the token type claim from
USER_ACCESS_TOKEN_TYPE_CLAIM instead of hardcoding it.
2026-10-09 17:37:15 +02:00
lebaudantoine 191e6d49d8 🔒️(backend) sign lobby participant id
The participant id returned by request-entry was the raw LiveKit UUID,
so anyone knowing it could poll the lobby and obtain the admitted
participant's LiveKit token.

Return the id signed with SECRET_KEY under a lobby-specific salt, and
reject unsigned, tampered or foreign-signed ids with a 400. The raw
UUID is still used internally for the cache and LiveKit identity.
2026-10-09 17:37:15 +02:00
lebaudantoine 5867e079c6 🔒️(frontend) restrict transit_code exchange to embedded context
Only run the transit_code exchange flow when the app is loaded in an
embedded context (i.e. inside an iframe).

Combined with the CSP rules that will restrict which origins are
allowed to embed the app, this gives us a client-side lever to
control which integrations can actually use this authentication
path.
2026-10-09 17:37:15 +02:00
lebaudantoine 96b28da7be 🔒️(backend) bind accepted lobby entries to the current username
Bind an accepted lobby entry to the username the participant had at
the moment of acceptance.

This prevents a participant, once accepted, from changing their
display name and reusing the same lobby grant to enter the room
under a different identity.
2026-10-09 17:37:15 +02:00
lebaudantoine 84450b6f3a 🔥(frontend) remove forgotten console.log calls
Drop leftover `console.log` calls that were accidentally left in the
frontend code and add noise to the browser console.
2026-10-09 17:37:15 +02:00
lebaudantoine 10ac8925fb ♻️(all) stop relying on cookies for the lobby flow
The lobby system relied on cookies to identify the participant
across the wait/enter cycle, which does not work in an iframe
context where our cookies are dropped.

Simplify the lobby behavior:

* The POST request that enters the lobby now returns the
  participant id in the response.
* The frontend passes that id back on subsequent requests to keep a
  sticky session while trying to enter the room.

This moves a bit more logic to the frontend but should be a
transparent refactoring, without decreasing the security of the
lobby flow.
2026-10-09 17:37:15 +02:00
lebaudantoine c2007929f4 🩹(frontend) unblock virtual background loading under bearer auth
Moving off cookie-based authentication surfaced several hard
issues, especially around loading virtual backgrounds: requests
used to be sent with cookies automatically, which trivially
authenticated those loads. With bearer tokens, those requests need
to be authenticated explicitly.

The situation is made harder by the fact that, when the custom
virtual background was introduced, some of the loading was done as
module-level, blocking imports that are not handled by React and
therefore live outside the normal auth flow.

Ship a functional patch to unblock third parties currently waiting
on this integration. The virtual background loading path should
definitely be refactored and simplified in a follow-up.
2026-10-09 17:37:15 +02:00
lebaudantoine ddf19deb82 ✨(frontend) support alternative auth via URL fragment
Wire the frontend to the backend's token exchange flow: when the
expected URL fragment is present, gate the app loading on exchanging
that fragment for a proper access token, which is then used to
interact with the API.

When no such fragment is present, the code path is a no-op and
should have minimal impact on load performance.
2026-10-09 17:37:15 +02:00
lebaudantoine 81876a10d4 ♻️(backend) use a dedicated auth scheme for LiveKit token auth
We now use `Authorization: Bearer <token>` to authenticate users
from the token exchange flow (used for iframe embeds).

Until now, the `Bearer` scheme was also reused for the alternative
LiveKit authentication, where a client presents its LiveKit token
issued by the backend to prove room membership on actions open to
any room participant. Sharing the scheme between the two flows is
not viable anymore.

Switch the LiveKit token authentication to a dedicated
`Authorization` scheme, so `Bearer` stays reserved for the iframe /
token-exchange flow.

Follow-up: a broader effort should look into harmonizing and
hardening the backend authentication stack of the app.
2026-10-09 17:37:15 +02:00
lebaudantoine f123c79741 ✨(backend) introduce a token exchange endpoint for iframe embeds
Some integrators render our videoconference inside an iframe, where
our cookie-based authentication does not work: our cookies are
SameSite=Lax/Strict, so the iframe drops them.

We looked at what Jitsi offers: a shared secret used to sign JWTs
that authenticate users coming from external services. Since we
already expose an external API where third parties authenticate as
a given user, it was simpler for us to add an exchange mechanism on
top of that.

Flow:

* Through the external API, mint a short-lived, single-use exchange
  code for a user.
* The third party hands that code to the frontend as a URL fragment.
* The frontend exchanges the code for a longer-lived JWT that can be
  used to query the regular API viewsets.

Known limitations and follow-ups:

* At some point it would be nice to shorten the JWT lifetime and
  add a refresh mechanism. This will be handled in a follow-up PR
  when actually needed.
* CSP rules to control which origins are allowed to embed the app
  in an iframe still need to be added.
* This alternative authentication cannot easily be scoped to a
  subset of endpoints without adding a lot of complexity, so it is
  accepted globally on the API for now.
2026-10-09 17:37:15 +02:00
lebaudantoine deb1ef9ed6 ⚡️(backend) reduce domain queries on the application token endpoint
Load allowed domains once and compare the lowercased email domain
in Python. For restricted applications with an existing user,
this reduces successful token requests from four queries to three.

Keep domain loading after credential validation so rejected
credentials require only the application lookup.

Add endpoint query-count tests for restricted and unrestricted
successes and invalid credentials.
2026-10-07 19:06:05 +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
leo f673c07cb8 ✨(backend) add per-recording encoding config to start-recording API
Add new options to query start-start recording API. A resolution
("540p", "720p", "1080p") and a profile ("talking_heads", "text", "mixed")
are resolved to provide a width, height, fps and bitrate which
are passed on to the encoder. Using profiles allows for some flexibility
on quality if necessary without changing front facing user config.

Co-authored-by: sarthakbahal <sarthakbahal.45@gmail.com>
2026-10-02 14:16:01 +02:00
lebaudantoine bd0329d162 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
The python:3.14.7-slim base image ships libpcre2-8-0
10.46-1~deb13u2, which is affected by CVE-2026-103111 (HIGH):
an out-of-bounds write triggered by a crafted regular expression.

Explicitly install libpcre2-8-0 in the base stage so apt pulls
the patched 10.46-1~deb13u3 from trixie-security. All stages
(builder, development, production) inherit the fix.

This line can be dropped once an upstream python slim image
ships the patched package.
2026-10-01 16:37:29 +02:00
lebaudantoine cedaa32ab7 🔒️(backend) fix HIGH CVEs in Django and urllib3
Address the following HIGH severity CVEs reported by Trivy on the
backend image:

* Django 5.2.16 → 5.2.17
  - CVE-2026-15307 — remote code execution via GeoDjango spatial
    lookups.

* urllib3 2.7.0 → 2.8.0
  - CVE-2026-97687 — traffic interception via HTTPS proxy TLS
    configuration override.
  - CVE-2026-97689 — denial of service via unbounded memory
    allocation in the chunk parser.
2026-10-01 16:37:29 +02:00
lebaudantoine 22adccb353 👷(ci) ignore unfixed Debian CVEs in trivy scans
The trivy scan fails on HIGH vulnerabilities found in the Debian 13
base images (util-linux, acl, ncurses, systemd and perl-base). None
of them has a fixed version available yet, so there is nothing we
can upgrade to clear them.

List these CVEs in a shared .github/.trivyignore and pass it to
every image scan, so the scan stays blocking for any new HIGH or
CRITICAL vulnerability. Remove the entries once Debian ships a fix.
2026-10-01 00:13:36 +02:00
lebaudantoine 3ab651d6c7 👷(ci) pin the shared CI repo to v0.0.1
Instead of referencing the shared CI repo on `main`, pin it to the
initial tagged version `v0.0.1`, so the CI behavior is stable and
does not silently change when the shared repo is updated.
2026-10-01 00:13:36 +02:00
lebaudantoine 919af928aa 🚨(ci) fix the shellcheck job
Get the shellcheck CI job to pass again by addressing the issues it
flagged across our shell scripts.

Note: I am not 100% sure of every fix applied here. Reviewers should
feel free to challenge specific changes and suggest better ones
where relevant.
2026-10-01 00:13:36 +02:00
lebaudantoine 3ed38f1c48 🚨(ci) fix the spellcheck job
Get the spellcheck CI job to pass again by:

* Fixing the actual spelling issues it caught in the project.
* Excluding generated files from the scan, since they are not
  written by us.
* Excluding translation files, whose content is not necessarily in
  English and would trigger false positives.
2026-10-01 00:13:36 +02:00
lebaudantoine f172c5795e 👷(ci) add Menshen scan for GitHub Actions vulnerabilities
Wire Menshen into the CI to scan the GitHub Actions we use and flag
vulnerable ones, following the same approach as other projects that
recently adopted it.

Note: I am not fully sure about the current setup. Reviewers should
feel free to adjust the configuration or the integration point as
they see fit.
2026-10-01 00:13:36 +02:00
lebaudantoine 262b168414 🔥(ci) drop unused Crowdin workflows
The Crowdin workflows were not used by the project and had turned
into dead CI code.

Remove them to reduce noise and keep the CI configuration limited
to what is actually running.
2026-10-01 00:13:36 +02:00
lebaudantoine 6c371c8cb3 👷(ci) migrate CI to the shared workflows repository
First iteration of a migration toward a centralized repository
containing our shared CI logic.

Goals:

* Manage GitHub Actions version upgrades in one place.
* Make it easier to audit what actually runs in CI from a security
  perspective.
* Avoid duplicating CI logic across projects and having each
  repository slowly diverge over time.
* Centralize as many of our custom GitHub Actions as possible,
  including some that still live in the old `numerique-gouv`
  organization.
* Centralize the Renovate configuration alongside the workflows.

Inspired by the Accounts project, which recently simplified and
reorganized its CI setup.

This PR starts moving meet's CI to the shared repository so we can
validate the approach on a real project. For now, reusable
workflows are pinned to `main`; once we agree on the structure and
content of the central repository, they should be pinned to a
specific commit SHA instead.
2026-10-01 00:13:36 +02:00
lebaudantoine 1a8906c0a1 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
Address the following CVEs in util-linux, reported by Cyberwatch on
the agents image. The python:3.14.6-slim tag is no longer rebuilt
and still ships util-linux 2.41-5. Bump the base image to
python:3.14.7-slim, which ships the patched 2.41.5-0+deb13u1
(DSA-6442-1), to cover them all:

* CVE-2026-53612 (7.0) — TOCTOU in mount post-mount ownership/mode
  changes.
* CVE-2026-53613 (7.0) — TOCTOU in mount via ancestor directory swap.
* CVE-2026-53614 (7.0) — SUID mount(8) nosuid/noexec bypass via
  `LIBMOUNT_FORCE_MOUNT2`.
* CVE-2026-13595 (5.3) — flaw in the libblkid library.
* CVE-2026-27456 (4.7) — TOCTOU in SUID mount(8) when setting up
  loop devices.
2026-09-30 16:21:22 +02:00
lebaudantoine 99ba8e330e 🐛(frontend) enforce recording-mode permissions on the checkboxes
Permissions on the recording panel checkboxes were not properly
enforced. A user with only partial access to some recording modes
could still tick a mode's checkbox and, for example, launch a
transcription from the recording panel even though they were not
authorized to.

Gate each checkbox on the user's actual permissions so that only
authorized modes can be started from the panel.
2026-09-30 15:15:19 +02:00
lebaudantoine 059e5f1ec4 🔒️(backend) add a daily cap on room creation
The per-minute room creation throttle absorbs bursts but does not stop
a compromised account from steadily creating rooms over hours or days.

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

Tests use a controllable clock and patch rates with monkeypatch so they
are restored after each test.
2026-09-30 14:57:10 +02:00
Lebaud Antoine 39ab9359e4 🔒️(backend) throttle meeting link generation
Add throttling on the endpoint used to generate meeting links, so a
compromised authenticated account cannot silently generate thousands
of links without hitting any suspicious errors or alerts.

The limits are set high enough not to affect legitimate usage, while
capping the damage a leaked account can do.
2026-09-30 14:57:10 +02:00
lebaudantoine d0a0d60ece 🔖(minor) bump release to 1.33.0 2026-09-30 13:03:41 +02:00
lebaudantoine 27bd136741 🩹(doc) fix changelog organization after a bad rebase
The CHANGELOG entries ended up in the wrong order after a rebase
performed while merging a recent PR.

Restore the intended organization of the entries so the file reads
correctly again.
2026-09-30 12:29:39 +02:00
lebaudantoine ad2ca6b4ef 🔒️(backend) fix critical and high CVEs in PyJWT
Bump PyJWT from 2.13.0 to 2.14.0 to address the following CVEs
reported by Trivy:

* CVE-2026-102268 (CRITICAL)
* CVE-2026-102266 (HIGH) — authentication bypass via empty HMAC
  key acceptance.
* CVE-2026-102267 (HIGH) — verification key substitution via
  unvalidated JWKS redirects.
* CVE-2026-102271 (HIGH) — authentication bypass via acceptance
  of DER public keys as HMAC.
* CVE-2026-102272 (HIGH) — token forgery via improper Unicode
  byte-order mark handling.
* CVE-2026-102273 (HIGH) — token forgery via acceptance of
  public JWK containers as HMAC.
2026-09-30 12:18:37 +02:00
leo 4071984f8e ⬆️(dependencies) update python dependencies
Update python dependencies.

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

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

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

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

For each of recordings, transcripts and summaries:

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

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

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

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

Delete the local templates so this project uses the organization
defaults, reducing the amount of code we maintain and centralizing
the practice across repositories.
2026-09-28 17:03:41 +02:00
228 changed files with 10645 additions and 4969 deletions
+9
View File
@@ -0,0 +1,9 @@
[codespell]
# Files that are not English, or generated
skip = ./.git,*.pdf,*.po,*.pot,*.json,*.lock,package-lock.json,
./LICENSES,
./src/summary/summary/core/locales,
./src/summary/summary/core/prompt.py
# Valid words in French (connexion) or in the code (statics)
ignore-words-list = connexion,statics
check-filenames = true
+20
View File
@@ -0,0 +1,20 @@
# Debian 13 base image (python:3.14-slim): no fixed version available yet.
# Review regularly and remove entries once Debian ships a fix.
# util-linux
CVE-2026-76642
CVE-2026-78408
CVE-2026-78409
CVE-2026-78410
# acl
CVE-2026-54369
# ncurses
CVE-2025-69720
# systemd
CVE-2026-16742
# perl-base (fix deferred by Debian)
CVE-2026-9538
-28
View File
@@ -1,28 +0,0 @@
---
name: 🐛 Bug Report
about: If something is not working as expected 🤔.
---
## Bug Report
**Problematic behavior**
A clear and concise description of the behavior.
**Expected behavior/code**
A clear and concise description of what you expected to happen (or code).
**Steps to Reproduce**
1. Do this...
2. Then this...
3. And then the bug happens!
**Environment**
- Meet version:
- Platform:
**Possible Solution**
<!--- Only if you have suggestions on a fix for the bug -->
**Additional context/Screenshots**
Add any other context about the problem here. If applicable, add screenshots to help explain.
-23
View File
@@ -1,23 +0,0 @@
---
name: ✨ Feature Request
about: I have a suggestion (and may want to build it 💪)!
---
## Feature Request
**Is your feature request related to a problem or unsupported use case? Please describe.**
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
**Describe the solution you'd like**
A clear and concise description of what you want to happen. Add any considered drawbacks.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Discovery, Documentation, Adoption, Migration Strategy**
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
Maybe a screenshot or design?
**Do you want to work on it through a Pull Request?**
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
@@ -1,22 +0,0 @@
---
name: 🤗 Support Question
about: If you have a question 💬, or something was not clear from the docs!
---
<!-- ^ Click "Preview" for a nicer view! ^
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
**Topic**
What's the general area of your question: for example, docker setup, database schema, search functionality,...
**Question**
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
-11
View File
@@ -1,11 +0,0 @@
## Purpose
Description...
## Proposal
Description...
- [] item 1...
- [] item 2...
+19
View File
@@ -0,0 +1,19 @@
name: Changelog Workflow
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
changelog:
uses: suitenumerique/ci/.github/workflows/_changelog.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
+71 -205
View File
@@ -11,180 +11,30 @@ permissions:
contents: read
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install uv
if: always()
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Lint commit messages added to main
if: always()
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g --ignore-scripts yarn@1.22.22
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
- name: Lint code with pylint
run: uv run --no-sync --no-build pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras --no-build
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
lint-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
lint-python:
name: lint ${{ matrix.service }}
strategy:
fail-fast: false
matrix:
include:
- service: backend
working_directory: src/backend
pylint_targets: meet demo core
- service: agents
working_directory: src/agents
pylint_targets: ""
- service: summary
working_directory: src/summary
pylint_targets: ""
uses: suitenumerique/ci/.github/workflows/_python-lint.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
working_directory: ${{ matrix.working_directory }}
python_version: "3.13"
pylint_targets: ${{ matrix.pylint_targets }}
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
@@ -226,8 +76,9 @@ jobs:
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_REGION_NAME: local
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
@@ -237,47 +88,33 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Build or restore the mail templates
uses: suitenumerique/ci/actions/mail-templates@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
- name: Start MinIO
# Creates the access key and the bucket on startup
- name: Start Garage
run: |
docker pull quay.io/minio/minio
docker run -d --name minio \
docker run -d --name garage \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
quay.io/minio/minio server --console-address :9001 /data
-v "${GITHUB_WORKSPACE}/docker/files/etc/garage/garage.toml:/etc/garage.toml:ro" \
-e "GARAGE_RPC_SECRET=$(openssl rand -hex 32)" \
-e "GARAGE_DEFAULT_ACCESS_KEY=meet-access-key" \
-e "GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key" \
-e "GARAGE_DEFAULT_BUCKET=meet-media-storage" \
dxflrs/garage:v2.4.1 \
/garage server --single-node --default-bucket
# Tool to wait for a service to be ready
- name: Install Dockerize
- name: Wait for Garage to be ready
run: |
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -310,9 +147,9 @@ jobs:
env:
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_ENDPOINT_URL: "garage:9000"
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
@@ -324,6 +161,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install ffmpeg
run: |
@@ -351,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
@@ -361,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:
@@ -371,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
@@ -392,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
+14
View File
@@ -0,0 +1,14 @@
name: Project quality Workflow
on:
pull_request:
permissions:
contents: read
jobs:
quality:
uses: suitenumerique/ci/.github/workflows/_project-quality.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
print_check_paths: src/backend src/summary src/agents
codespell_ignore_words: "unsecure"
-33
View File
@@ -1,33 +0,0 @@
name: Download Crowdin translations
on:
workflow_dispatch:
types: [file-fully-translated]
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Download Crowdin files
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: "New Crowdin translations"
pull_request_body: "New Crowdin pull request with translations"
pull_request_base_branch_name: "main"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
+49 -252
View File
@@ -1,5 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
name: Docker images
run-name: Docker images
on:
workflow_dispatch:
@@ -15,265 +15,62 @@ on:
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
target: backend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push:
name: ${{ matrix.service }}
strategy:
fail-fast: false
matrix:
include:
- service: backend
image_name: lasuite/meet-backend
context: .
file: ./Dockerfile
target: backend-production
- service: frontend
image_name: lasuite/meet-frontend
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
- service: frontend-dinum
image_name: lasuite/meet-frontend-dinum
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
- service: summary
image_name: lasuite/meet-summary
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
- service: agents
image_name: lasuite/meet-agents
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
uses: suitenumerique/ci/.github/workflows/_docker-publish.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
image_name: ${{ matrix.image_name }}
context: ${{ matrix.context }}
file: ${{ matrix.file }}
target: ${{ matrix.target }}
docker_user: "1001:127"
is_multi_platform: ${{ startsWith(github.ref, 'refs/tags/v') }}
should_push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
trivy_scan: true
trivy_ignore_files: ./.github/.trivyignore
secrets:
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
- build-and-push
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
- uses: suitenumerique/ci/actions/argocd-webhook-notification@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
+7 -23
View File
@@ -1,33 +1,17 @@
name: Release Chart
run-name: Release Chart
name: Release Helm chart
on:
push:
branches:
- main
paths:
- src/helm/meet/**
permissions:
contents: read
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
token: ${{ secrets.GITHUB_TOKEN }}
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
+21
View File
@@ -0,0 +1,21 @@
name: Security analysis
on:
push:
branches:
- main
pull_request:
branches:
- "**"
permissions: {}
jobs:
zizmor:
permissions:
contents: read
actions: read
security-events: write
uses: suitenumerique/ci/.github/workflows/_zizmor.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
config: .github/zizmor.yml
+5
View File
@@ -0,0 +1,5 @@
rules:
unpinned-uses:
config:
policies:
"suitenumerique/*": ref-pin
+73 -1
View File
@@ -8,6 +8,78 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) introduce a token exchange endpoint for iframe embeds
### Changed
- ⚡️(backend) hash application secrets with SHA-256
- ⚡️(backend) reduce domain queries on the application token endpoint
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
- ♻️(all) stop relying on cookies for the lobby flow
### Fixed
- 🔒️(backend) prevent editing client id and secret in Django admin
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
- 🔒️(backend) bind accepted lobby entries to the current username
- 🔒️(backend) sign lobby participant id
## [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
- 🐛(frontend) enforce recording-mode permissions on the checkboxes
- 🔒️(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
### Added
- ✨(backend) purge rooms inactive for a configurable period
- 🔨(makefile) add targets to list and download files stored in Garage
### Changed
- ⬆️(backend) update python dependencies
- ⬆️(summary) update python dependencies
- ⬆️(agents) update python dependencies
- ♻️(agents) replace the minio client by boto3
- 🔧(compose) replace MinIO by Garage for local development
- 🔧(helm) point media services to Garage by default
- 💥(backend) replace recording encoding options with a profile model
### Fixed
- 🔒️(backend) fix critical and high CVEs in PyJWT
- ⚡️(frontend) disable posthog-js periodic feature flag reloads
## [1.32.1] - 2026-09-25
### Fixed
@@ -374,7 +446,7 @@ and this project adheres to
### Fixed
- ♿️(frontend) improve accessibilty of the Effects panel #1401
- ♿️(frontend) improve accessibility of the Effects panel #1401
## [1.20.0] - 2026-06-12
+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 ----
+53 -3
View File
@@ -69,6 +69,22 @@ LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT)
# -- Frontend
PATH_FRONT = ./src/frontend
# -- Storage
GARAGE_BUCKET = meet-media-storage
STORAGE_FOLDERS = recordings transcripts summaries
STORAGE_DIRS = $(addprefix data/,$(STORAGE_FOLDERS))
COMPOSE_RUN_AWS = $(COMPOSE_RUN) --user $(DOCKER_USER)
AWS_CLI = garage-cors --endpoint-url=http://garage:9000
# Extensions listed in each folder (skips the Egress manifests in recordings/)
recordings_EXTENSIONS = mp4 ogg
transcripts_EXTENSIONS = json
summaries_EXTENSIONS = txt
# $(1): folder. Lists its objects with a known extension, most recent first
storage_list = s3api list-objects-v2 --bucket $(GARAGE_BUCKET) \
--prefix $(1)/
storage_query = reverse(sort_by(Contents[?$(foreach ext,$($(1)_EXTENSIONS), \
ends_with(Key, `".$(ext)"`) ||) `false`] || `[]`, &LastModified))
# ==============================================================================
# RULES
@@ -77,6 +93,9 @@ default: help
data/media:
@mkdir -p data/media
$(STORAGE_DIRS):
@mkdir -p $@
data/static:
@mkdir -p data/static
@@ -85,6 +104,7 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/garage \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
@@ -149,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
@@ -166,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
@@ -240,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)
@@ -258,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
@@ -317,12 +341,38 @@ env.d/development/summary:
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/garage:
sed "s/^GARAGE_RPC_SECRET=.*/GARAGE_RPC_SECRET=$$(openssl rand -hex 32)/" \
env.d/development/garage.dist > env.d/development/garage
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
env.d/development/metadata_collector:
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
# -- Storage
recordings-download-latest: ## download the latest recording from Garage into data/recordings
transcripts-download-latest: ## download the latest transcript from Garage into data/transcripts
summaries-download-latest: ## download the latest summary from Garage into data/summaries
$(STORAGE_FOLDERS:%=%-download-latest): %-download-latest: data/%
@key=$$($(COMPOSE_RUN_AWS) -T $(AWS_CLI) $(call storage_list,$*) \
--query '$(call storage_query,$*)[0].Key' --output text) && \
if [ "$$key" = "None" ]; then echo "No $* found"; exit 1; fi && \
$(COMPOSE_RUN_AWS) --volume $(CURDIR)/data/$*:/aws/data/$* \
$(AWS_CLI) s3 cp "s3://$(GARAGE_BUCKET)/$$key" data/$*/
.PHONY: $(STORAGE_FOLDERS:%=%-download-latest)
recordings-list: ## list recordings stored in Garage, most recent first
transcripts-list: ## list transcripts stored in Garage, most recent first
summaries-list: ## list summaries stored in Garage, most recent first
$(STORAGE_FOLDERS:%=%-list): %-list:
@$(COMPOSE_RUN_AWS) $(AWS_CLI) $(call storage_list,$*) \
--query '$(call storage_query,$*)[].{Date: LastModified, Key: Key, "Size (bytes)": Size}' \
--output table
.PHONY: $(STORAGE_FOLDERS:%=%-list)
# -- Internationalization
env.d/development/crowdin:
+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
+210
View File
@@ -16,6 +16,216 @@ the following command inside your docker container:
## [Unreleased]
### Marketing / Brevo integration now uses `django-lasuite`
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.
**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.
**Configuration changes.** The following environment variables / settings are
**removed** and no longer read:
- `MARKETING_SERVICE_CLASS`
- `BREVO_API_KEY`
- `BREVO_API_CONTACT_LIST_IDS`
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
- `BREVO_API_TIMEOUT`
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
| Variable | Default | Description |
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
⚠️ The default backend is now a **dummy** (no-op). If you previously used
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
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}}
Migration mapping:
- `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)
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).
### Recording encoding settings replaced by a resolution/profile model
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
fps and video bitrate.
**The following environment variables are no longer read. If they are still set in
your deployment they are silently ignored, and your recordings will be encoded with
the new defaults instead of your tuned values.**
| Removed variable | Replaced by |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
encoder's choice) instead of `4.0`.
#### If you never set `RECORDING_ENCODING_ENABLED=True`
The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
Video output is therefore unchanged.
Audio and keyframing may not be. These values are now sent explicitly as advanced
`EncodingOptions` rather than relying on LiveKit's preset, so
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
now apply to every recording. They previously applied only when
`RECORDING_ENCODING_ENABLED` was `True`. **If you set either of them while the
feature was disabled, they had no effect and now do**; check them before upgrading.
If you never set them, no action is required: 128 kbps AAC is what the preset used,
and the keyframe interval now defaults to `0`, which leaves the field unset so the
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
if you want fixed 4-second keyframes (the value the setting defaulted to while it
was gated behind `RECORDING_ENCODING_ENABLED`).
To keep letting LiveKit pick the encoding instead, set either default to an empty
value:
```
RECORDING_ENCODING_DEFAULT_RESOLUTION=
RECORDING_ENCODING_DEFAULT_PROFILE=
```
#### If you had tuned `RECORDING_ENCODING_*` values
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
environment as a single-line Python/JSON dict literal (parsed with
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
add outer quotes in `.env`-style files):
```bash
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
```
Both maps are validated at startup and a malformed one raises a `ValueError`:
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
`kbps` map;
- every profile must define a `kbps` entry for **exactly** the keys of
`RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`; overriding one of the two maps usually
means overriding both;
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
when non-empty, must be keys of their respective map.
#### Breaking: custom worker services must accept `encoding_options`
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
are already updated.
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
now always passes it as a keyword when the recording carries no per-recording encoding:
```python
# before
def start(self, room_id: str, recording_id: str) -> str: ...
# now
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str: ...
```
#### Optional: per-recording encoding
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
start-recording API accepts an `encoding` object
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional. It
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) that overrides the default for
a single recording. It does not enable or disable the
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
settings either way. Leaving it at `False` preserves the previous behaviour, where
every recording uses the server-side encoding: requests carrying
`options.encoding` are rejected with a `400` before the recording is created, so
nothing is persisted and no egress is started.
Before enabling it:
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
- as of this implementation, the frontend never sends `encoding`
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
egress has no video encoding to configure.
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('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', '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',
+8 -8
View File
@@ -55,12 +55,12 @@ function _docker_compose() {
function _dc_run() {
_set_user
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
user_args=()
if [ -n "$USER_ID" ]; then
user_args=("--user=$USER_ID")
fi
_docker_compose run --rm $user_args "$@"
_docker_compose run --rm "${user_args[@]}" "$@"
}
# _dc_exec: wrap docker compose exec command
@@ -74,12 +74,12 @@ function _dc_exec() {
echo "🐳(compose) exec command: '\$@'"
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
user_args=()
if [ -n "$USER_ID" ]; then
user_args=("--user=$USER_ID")
fi
_docker_compose exec $user_args "$@"
_docker_compose exec "${user_args[@]}" "$@"
}
# _django_manage: wrap django's manage.py command with docker compose
+1 -1
View File
@@ -40,7 +40,7 @@ if [ -n "$CUSTOM_LOGO_URL" ]; then
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
mv -f "$TMP_FILE" "$LOGO_FILE"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
echo "[custom-logo] INFO: Custom logo downloaded successfully"
fi
mv src/backend/* ./
+1 -1
View File
@@ -7,7 +7,7 @@ gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
trap 'pkill -TERM -P $$' SIGTERM
# wait for a single child to finish,
wait -n
+4 -5
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-meet}
SECRET_NAME=${2:-bitwarden-cli-meet}
TEMP_SECRET_FILE=$(mktemp)
@@ -30,10 +29,10 @@ check_secret_exists() {
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
read -r -p "Enter your Vaultwarden email login: " LOGIN
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -p "Enter your Vaultwarden server url: " URL
read -r -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
@@ -77,7 +76,7 @@ main() {
exit 0
fi
echo -e ${TEMP_SECRET_FILE}
echo -e "${TEMP_SECRET_FILE}"
get_user_input
echo -e "\nCreating Vaultwarden secret…"
+2 -2
View File
@@ -3,7 +3,7 @@
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
cat <<'EOF' >$PRE_COMMIT_FILE
cat <<'EOF' >"$PRE_COMMIT_FILE"
#!/bin/bash
# directories containing potential secrets
@@ -27,4 +27,4 @@ for d in $DIRS; do
done
EOF
chmod +x $PRE_COMMIT_FILE
chmod +x "$PRE_COMMIT_FILE"
+1 -1
View File
@@ -68,7 +68,7 @@ fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
read -r -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
git submodule update --init --recursive
# shellcheck disable=SC2016
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
+1 -1
View File
@@ -8,6 +8,6 @@ environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
helmfile -e "$env" -f src/helm/helmfile.yaml lint || exit 1
echo -e "\n"
done
+38 -32
View File
@@ -9,42 +9,52 @@ services:
redis:
image: redis:5
ports:
- "6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
minio:
garage:
user: ${DOCKER_USER:-1000}
image: quay.io/minio/minio
image: dxflrs/garage:v2.4.1
command: /garage server --single-node --default-bucket
env_file:
- env.d/development/garage
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
- GARAGE_DEFAULT_BUCKET=meet-media-storage
ports:
- '9000:9000'
- '9001:9001'
- '127.0.0.1:9000:9000'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
test: [ "CMD", "/garage", "health" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
- ./data/media:/var/lib/garage
createbuckets:
image: quay.io/minio/mc
# Garage denies cross-origin requests by default: allow the frontend to upload files
garage-cors:
image: amazon/aws-cli:2.37.1
environment:
- AWS_ACCESS_KEY_ID=meet-access-key
- AWS_SECRET_ACCESS_KEY=meet-secret-access-key
- AWS_DEFAULT_REGION=local
depends_on:
minio:
garage:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
command:
- s3api
- put-bucket-cors
- --endpoint-url=http://garage:9000
- --bucket=meet-media-storage
- '--cors-configuration={"CORSRules": [{"AllowedOrigins": ["http://localhost:3000"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
app-dev:
build:
@@ -70,7 +80,7 @@ services:
- postgresql
- mailcatcher
- redis
- createbuckets
- garage-cors
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
@@ -110,7 +120,7 @@ services:
- postgresql
- redis
- livekit
- minio
- garage
celery:
user: ${DOCKER_USER:-1000}
@@ -145,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:
@@ -164,7 +175,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:22-alpine
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -244,7 +255,7 @@ services:
- /app/.venv
depends_on:
- livekit
- minio
- garage
develop:
watch:
- action: rebuild
@@ -262,11 +273,6 @@ services:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
@@ -281,7 +287,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
celery-summary-transcribe:
container_name: celery-summary-transcribe
@@ -295,9 +301,9 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
@@ -315,9 +321,9 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
+1 -1
View File
@@ -163,7 +163,7 @@ in
REDIS_URL = "redis://127.0.0.1:6379/1";
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
# S3 / MinIO
# S3 / Garage
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
# OIDC
+1 -1
View File
@@ -1,7 +1,7 @@
# Bureautix proxy overrides
#
# Builds submitted through the Docker API of the Podman service get none of
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitly
# otherwise all connections fail during the build.
x-proxy-vars: &proxy-vars
+3 -1
View File
@@ -19,7 +19,9 @@ services:
<<: *keep-id
celery-dev:
<<: *keep-id
minio:
garage:
<<: *keep-id
garage-cors:
<<: *keep-id
node:
<<: *keep-id
+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' 'tiff>=4.7.2-r0' && \
apk del curl
USER nginx
+15
View File
@@ -0,0 +1,15 @@
# Garage configuration for local development only: single node, no replication.
# See https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
api_bind_addr = "[::]:9000"
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
s3_region = "local"
+33 -3
View File
@@ -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
@@ -17,9 +47,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from Garage
proxy_pass http://garage:9000/meet-media-storage/;
proxy_set_header Host garage:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
+32 -1
View File
@@ -13,7 +13,7 @@ These components rely on a few key services:
- PostgreSQL for storing data (users, rooms, recordings)
- Redis for caching and inter-service communication
- MinIO for storing files (room recordings)
- Garage for storing files (room recordings)
- Celery workers for meeting transcript (optional, required for AI beta features)
We provide two stack options for getting Visio up and running for development:
@@ -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
+41 -34
View File
@@ -93,13 +93,13 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
> [!NOTE]
@@ -130,52 +130,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
| Resolved value | GStreamer element | Property |
| ----------------------------------------- | ----------------- | ---------------------------------- |
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
| profile `fps` | capsfilter | `framerate=F/1` |
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
### Built-in profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
★ Recommended starting point for typical LaSuite Meet usage.
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
Environment variables for the **Low CPU / small file** profile:
```json
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
}
```
To change the default encoding applied to every recording:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+40
View File
@@ -0,0 +1,40 @@
# Room purge
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
## How it works
Each time LiveKit tells the backend that a room has started (`room_started` webhook), the backend records the date on the room (`last_started_at`).
A room is inactive when:
- it was last started more than `ROOM_INACTIVITY_DELETION_DAYS` days ago, or
- it was never started and was created more than `ROOM_INACTIVITY_DELETION_DAYS` days ago.
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
```bash
python manage.py purge_inactive_rooms # delete the inactive rooms
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
```
## Rooms that are kept
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
## What happens to a purged room
The room is deleted from the database, along with its accesses, its telephony PIN code, and the recording entries it still holds — the expired ones and those that were never saved, since any other recording would have protected the room — together with their own accesses.
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
+8 -7
View File
@@ -42,7 +42,7 @@ sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant S3 as S3 (Object Storage)
participant STT as WhisperX API
participant Docs as LaSuite Docs
@@ -50,7 +50,7 @@ sequenceDiagram
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
Summary->>Celery: Register task (transcribe-queue)
Celery->>MinIO: Fetch audio file
Celery->>S3: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
@@ -72,11 +72,12 @@ sequenceDiagram
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
| aws_storage_bucket_name | String | — | Name of the S3 bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3 storage. |
| aws_s3_access_key_id | String | — | Access key for S3. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3 requests. |
| aws_s3_region_name | String | — | Region used to sign S3 requests, passed as-is to boto3. |
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
+1 -1
View File
@@ -14,7 +14,7 @@ All services are required to run the minimalist instance of LaSuite Meet. Click
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
| **SMTP Service** | Email notifications | - |
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
## Software Requirements
+120 -116
View File
@@ -14,7 +14,7 @@ This document is a step-by-step guide that describes how to install LaSuite Meet
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
@@ -311,119 +311,123 @@ frontend:
These are the environmental options available on meet backend.
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
| FRONTEND_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 |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+33 -15
View File
@@ -24,9 +24,10 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=http://garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
@@ -63,23 +64,39 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_VERSION=2
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# Every video recording is encoded with parameters (height, width, fps, kbps) derived
# from the pair (profile, resolution) and passed to LiveKit as advanced EncodingOptions.
# Choose the available resolutions and profiles that default settings and users can
# pick from. They must be defined as a single-line dict literal (parsed with
# ast.literal_eval: double-quoted keys, no trailing comma, no outer quotes).
# Every profile must define a kbps entry for exactly the keys of
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS (validated at startup).
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
# Choose the default named resolution and profile to use by default. These values must
# be keys of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS and RECORDING_ENCODING_AVAILABLE_PROFILES.
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
# RECORDING_ENCODING_DEFAULT_PROFILE=full
# Default encoding values independent of resolution/profile
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Set to True to let the start-recording API override that default per recording
# with an `encoding` object, e.g. {"resolution": "720p", "profile": "talking_heads"}.
# RECORDING_CUSTOM_ENCODING_ENABLED=False
# Telephony
ROOM_TELEPHONY_ENABLED=True
@@ -87,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
+2
View File
@@ -0,0 +1,2 @@
# Filled with a random value by `make create-env-files`
GARAGE_RPC_SECRET=
+4 -3
View File
@@ -2,8 +2,9 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -1,6 +1,7 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
+8 -3
View File
@@ -2,11 +2,16 @@ APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_ENDPOINT_URL="garage:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
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"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"extends": ["github>suitenumerique/ci//renovate/default"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
+1 -1
View File
@@ -21,7 +21,7 @@ const { initI18n, translateUI } = require("../common/i18n");
document.querySelector("#close-msg").style.display = "block";
})
.catch((e) => {
console.error(`Error occured: ${e}`);
console.error(`Error occurred: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
+2 -1
View File
@@ -1,10 +1,11 @@
FROM python:3.14.6-slim AS base
FROM python:3.14.7-slim AS base
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
libpcre2-8-0 \
libssl3t64 \
&& rm -rf /var/lib/apt/lists/*
+35 -18
View File
@@ -6,9 +6,11 @@ import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
import boto3
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
@@ -28,8 +30,6 @@ from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
@@ -59,6 +59,30 @@ server = AgentServer(
server.setup_fnc = prewarm
def create_s3_client():
"""Create an S3 client for the configured endpoint and region.
The endpoint may be given with or without a scheme: the scheme always
follows AWS_S3_SECURE_ACCESS.
"""
endpoint = (
os.getenv("AWS_S3_ENDPOINT_URL", "")
.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
secure = os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true"
return boto3.client(
"s3",
endpoint_url=f"{'https' if secure else 'http'}://{endpoint}",
aws_access_key_id=os.getenv("AWS_S3_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
region_name=os.getenv("AWS_S3_REGION_NAME"),
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
@@ -121,18 +145,13 @@ class MetadataCollector:
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.s3_client = create_s3_client()
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
@@ -201,20 +220,18 @@ class MetadataCollector:
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=self.output_filename,
Body=data,
ContentType="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
except (BotoCoreError, ClientError):
logger.exception(
"Failed to upload meeting metadata",
)
+9 -9
View File
@@ -1,24 +1,24 @@
[project]
name = "agents"
version = "1.32.1"
version = "1.34.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.7.0",
"livekit-plugins-deepgram==1.7.0",
"livekit-plugins-silero==1.7.0",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"boto3==1.43.56",
"python-dotenv==1.2.3",
"protobuf==7.36.0",
"sentry-sdk==2.68.1",
"websockets==17.1",
"httpx==0.28.1",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.16.4",
]
[tool.uv]
+859 -854
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -279,9 +279,16 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
list_display = [
"name",
"slug",
"access_level",
"get_owner",
"created_at",
"last_started_at",
]
list_filter = ["access_level", "created_at", "last_started_at"]
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -476,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
@@ -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
),
+1
View File
@@ -17,6 +17,7 @@ class FeatureFlag:
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
}
@classmethod
+104 -2
View File
@@ -7,13 +7,19 @@ from typing import Literal
from urllib.parse import quote
from django.conf import settings
from django.core import signing
from django.core.exceptions import SuspiciousOperation
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer
from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
@@ -244,6 +250,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class EncodingConfig(BaseModel):
"""Configuration options for recording encoding.
The allowed `resolution` and `profile` values are derived at validation time
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
to those maps is enough to make it accepted here.
Attributes:
resolution: Target video resolution.
profile: Encoding profile to fps and kbps. When `None`,
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
"""
resolution: str
profile: str | None = None
model_config = {"extra": "forbid"}
@field_validator("resolution")
@classmethod
def _validate_resolution(cls, value):
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
if value not in allowed:
raise ValueError(
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
)
return value
@field_validator("profile")
@classmethod
def _validate_profile(cls, value):
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
if value is None:
return None
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
if value not in allowed:
raise ValueError(
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
)
return value
class RecordingOptions(BaseModel):
"""Configuration options for recording.
@@ -264,7 +313,7 @@ class RecordingOptions(BaseModel):
transcribe: bool | None = None
collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
encoding: EncodingConfig | None = None
model_config = {"extra": "forbid"}
@@ -287,11 +336,47 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
help_text="Recording options",
)
def validate_options(self, value: RecordingOptions):
"""Validate that custom encoding is enabled if encoding options are passed."""
if (
value is not None
and value.encoding is not None
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
):
# Per-recording encoding selection is gated by
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
# and RECORDING_ENCODING_DEFAULT_PROFILE.
raise serializers.ValidationError(
"Per-recording encoding selection is disabled."
)
return value
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
participant_id = serializers.CharField(
required=False, allow_null=True, max_length=128
)
@staticmethod
def sign_participant_id(participant_id):
"""Sign with Django's SECRET_KEY and a lobby-specific namespace."""
return signing.Signer(salt="core.lobby.participant").sign(participant_id)
def validate_participant_id(self, value):
"""Require a valid server signature before looking up a participant."""
if value is None:
return None
try:
participant_id = signing.Signer(salt="core.lobby.participant").unsign(value)
except signing.BadSignature as exc:
raise serializers.ValidationError(
"Invalid participant credential."
) from exc
return serializers.UUIDField().run_validation(participant_id)
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -599,3 +684,20 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
# useless bad requests
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class TransitCodeSerializer(BaseValidationOnlySerializer):
"""Validate the single-use transit code sent to the exchange endpoint."""
code = serializers.CharField(trim_whitespace=True)
def validate_code(self, value):
"""Reject codes whose length cannot match a generated one."""
# Calculates urlsafe_b64encode length without padding
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
if len(value) != expected_length:
raise serializers.ValidationError("Invalid transit code format.")
return value
+54 -11
View File
@@ -1,11 +1,11 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message
from . import serializers
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
@@ -20,6 +20,33 @@ class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RoomCreationUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle room creation per authenticated user.
Can be declared at the viewset level: every action other than "create"
is left unthrottled, so the same class can be reused on any viewset
exposing a room creation endpoint.
"""
scope = "room_creation"
def get_cache_key(self, request, view):
"""Throttle only room creations."""
if getattr(view, "action", None) != "create":
return None
return super().get_cache_key(request, view)
class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
"""Cap room creation per authenticated user over a day.
Complements the short-term RoomCreationUserRateThrottle, which absorbs
bursts but lets a user steadily create rooms over hours or days.
"""
scope = "room_creation_daily"
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
@@ -42,13 +69,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling — the cookie will be set on the first
response, and throttling will apply from the second request onward.
Only throttle requests carrying a participant identifier. The
identifier is returned by the first request-entry response and
echoed back by the client from the second request onward, which is
when throttling starts applying.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Keying on the identifier rather than the IP address prevents
penalising multiple users behind the same NAT/proxy, and is
consistent with how the lobby identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
@@ -58,10 +86,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
serializer = serializers.RequestEntrySerializer(data=request.data)
if not serializer.is_valid():
return None
if participant_id is None:
return None # No throttling for cookieless requests
participant_id = serializer.validated_data.get("participant_id")
if not participant_id:
return None # No throttling for unidentified requests
return self.cache_format % {
"scope": self.scope,
@@ -97,3 +129,14 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous transit code exchange attempts.
Abuse mitigation only, not a security boundary: DRF throttling is
best-effort. The security of the exchange rests on the codes'
entropy and single use.
"""
scope = "exchange_access_token"
+118 -6
View File
@@ -55,12 +55,14 @@ from core.recording.worker.exceptions import (
RecordingStopError,
)
from core.recording.worker.factories import (
build_encoding_options,
get_worker_service,
)
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.jwt_token import JwtTokenService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
@@ -81,6 +83,7 @@ from core.services.room_roles import (
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.services.transit_code import TransitCodeService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
@@ -165,6 +168,98 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data
)
@decorators.action(
detail=False,
methods=["post"],
url_path="exchange-access-token",
permission_classes=[],
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
)
@FeatureFlag.require("user_access_token")
def exchange_access_token(self, request):
"""Exchange a single-use transit code for a user access token.
The endpoint is unauthenticated: the transit code itself, an opaque
random string obtained through the external API and delivered to
the embedded frontend via a URL fragment, is the credential. Each
code can be exchanged exactly once (consuming it deletes it from
the cache); replaying a consumed code is denied and logged.
The issued JWT authenticates the user the code was minted for on
the whole core API, exactly like a session cookie would (similar
to lib-jitsi-meet's token authentication), and never appears in
any URL. Role-based permissions apply unchanged.
"""
if request.user and request.user.is_authenticated:
logger.warning(
"Transit code exchange refused: request is already "
"session-authenticated (user_id=%s)",
request.user.id,
)
raise drf_exceptions.PermissionDenied("Already authenticated.")
serializer = serializers.TransitCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
if code_data is None:
logger.warning("Invalid, expired or already used transit code")
raise drf_exceptions.PermissionDenied(
"Invalid, expired or already used transit code."
)
# Re-check the user at exchange time so that a deactivation after
# the transit code was minted is taken into account.
user_id = code_data["user_id"]
try:
user = models.User.objects.get(id=user_id, is_active=True)
except models.User.DoesNotExist as e:
raise drf_exceptions.PermissionDenied(
"This account can no longer access the application."
) from e
client_id = code_data.get("client_id")
if not models.Application.has_active_scope(
client_id, models.ApplicationScope.USERS_SESSION
):
logger.warning(
"Transit code exchange refused: application '%s' no longer "
"holds the '%s' grant",
client_id,
models.ApplicationScope.USERS_SESSION,
)
raise drf_exceptions.PermissionDenied(
"This application can no longer create user sessions."
)
token_service = JwtTokenService(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
)
data = token_service.generate_jwt(
user,
"user:access",
{
"client_id": client_id or "unknown",
"token_type": settings.USER_ACCESS_TOKEN_TYPE_CLAIM,
},
)
# Log for auditing
logger.info(
"User access token issued from transit code: user_id=%s, client_id=%s",
user_id,
client_id,
)
return drf_response.Response(data)
class RoomViewSet(
mixins.CreateModelMixin,
@@ -180,6 +275,10 @@ class RoomViewSet(
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
throttle_classes = [
throttling.RoomCreationUserRateThrottle,
throttling.RoomCreationDailyUserRateThrottle,
]
def get_object(self):
"""Allow getting a room by its slug."""
@@ -323,12 +422,20 @@ class RoomViewSet(
options = serializer.validated_data.get("options")
room = self.get_object()
options_data = options.model_dump(exclude_none=True) if options else {}
if options is not None and options.encoding is not None:
# Persist the resolved encoding (concrete width/height/framerate/
# bitrate) alongside the requested resolution/profile for traceability.
options_data["encoding"]["resolved"] = build_encoding_options(
options.encoding.resolution, options.encoding.profile
)
try:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
options=options_data,
)
models.RecordingAccess.objects.create(
user=self.request.user,
@@ -432,13 +539,18 @@ class RoomViewSet(
participant, livekit = lobby_service.request_entry(
room=room,
request=request,
user=request.user,
**serializer.validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
return response
return drf_response.Response(
{
**participant.to_dict(),
"id": serializers.RequestEntrySerializer.sign_participant_id(
participant.id
),
"livekit": livekit,
}
)
@decorators.action(
detail=True,
+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."""
+9 -2
View File
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
UserModel = get_user_model()
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
return None # No authentication attempted
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
# backend may recognize it.
return None
if len(parts) != 2:
raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>"
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
)
token = parts[1]
@@ -0,0 +1,79 @@
"""User access JWT authentication for the Meet core API.
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
session cookies are blocked) to authenticate requests on the core API with
a JWT, obtained by exchanging a single-use transit code (see
core.services.transit_code and the users exchange-access-token endpoint)
and passed as a Bearer header. The JWT itself never appears in any URL.
Similar to lib-jitsi-meet's token authentication, the token is bound to a
user, not to a resource: once authenticated, the request is treated
exactly like a session-authenticated one, and the existing role-based
permissions apply unchanged.
"""
import logging
from django.conf import settings
from rest_framework import exceptions
from core.external_api.authentication import BaseJWTAuthentication
from core.models import Application, ApplicationScope
logger = logging.getLogger(__name__)
class UserAccessJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for user access tokens.
Validates user access tokens issued by the users exchange-access-token
endpoint and authenticates the user they were issued for. A bearer
token that does not verify against the user access token secret is
deferred to the next authentication backend; a token that does verify
but carries wrong claims is rejected.
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
backend is entirely inert: `BaseJWTAuthentication.authenticate`
returns None before reading the Authorization header, deferring every
request to the next authentication backend.
"""
def __init__(self):
"""Initialize the backend with user access token settings."""
super().__init__(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
)
def validate_payload(self, payload):
"""Validate the token type and the issuance-audit claim.
Raises:
AuthenticationFailed: If the token verified against the user
access token secret but does not carry the expected
claims, or if the issuing application lost its grant.
"""
if payload.get("token_type") != settings.USER_ACCESS_TOKEN_TYPE_CLAIM:
logger.warning("Wrong 'token_type' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token type.")
client_id = payload.get("client_id")
if not client_id:
logger.warning("Missing 'client_id' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not Application.has_active_scope(client_id, ApplicationScope.USERS_SESSION):
logger.warning(
"User access token refused: application '%s' no longer "
"holds the '%s' grant",
client_id,
ApplicationScope.USERS_SESSION,
)
raise exceptions.AuthenticationFailed("Application access revoked.")
@@ -48,6 +48,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
self.is_enabled = is_enabled
self._token_service = None
self._token_type = token_type
if not self.is_enabled:
return
@@ -73,7 +74,10 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
if (
not auth_header
or auth_header[0].lower() != self._token_type.lower().encode()
):
# Defer to next authentication backend
return None
@@ -159,7 +163,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
return self._token_type
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
+95 -27
View File
@@ -20,8 +20,60 @@ class BaseScopePermission(permissions.BasePermission):
scope_map: Dict[str, str] = {}
def get_required_scope(self, view):
"""Return the scope required by the view's current action.
Returns:
The required scope, or None for an unsupported method so
DRF's router can answer 405.
Raises:
PermissionDenied: If the action is not in scope_map (deny by
default).
"""
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
action = getattr(view, "action", None)
if not action:
# DRF routers return a 405 for unsupported methods
return None
required_scope = self.scope_map.get(action)
if not required_scope:
# Action not in scope_map, deny by default
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
return required_scope
def get_token_scopes(self, request):
"""Extract and normalize the scopes claimed by the token."""
token_scopes = (request.auth or {}).get("scope")
if not token_scopes:
return []
# Ensure scopes is a list (handle both list and space-separated string)
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
return self.strip_scope_prefix(token_scopes)
@staticmethod
def strip_scope_prefix(token_scopes):
"""Strip the OIDC resource server prefix, when configured."""
if settings.OIDC_RS_SCOPES_PREFIX:
return [
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
for scope in token_scopes
]
return token_scopes
def has_permission(self, request, view):
"""Check if the JWT token contains the required scope for this action.
"""Check if the token claims the scope required by this action.
Args:
request: DRF request object with authenticated user
@@ -33,38 +85,15 @@ class BaseScopePermission(permissions.BasePermission):
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
action = getattr(view, "action", None)
if not action:
# DRF routers return a 405 for unsupported methods
required_scope = self.get_required_scope(view)
if required_scope is None:
return True
required_scope = self.scope_map.get(action)
if not required_scope:
# Action not in scope_map, deny by default
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
token_payload = request.auth
token_scopes = token_payload.get("scope")
token_scopes = self.get_token_scopes(request)
if not token_scopes:
raise exceptions.PermissionDenied("Insufficient permissions.")
# Ensure scopes is a list (handle both list and space-separated string)
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
for scope in token_scopes
]
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
@@ -73,6 +102,37 @@ class BaseScopePermission(permissions.BasePermission):
return True
class ApplicationScopePermission(BaseScopePermission):
"""Scope-based permission for application-authenticated endpoints."""
@staticmethod
def strip_scope_prefix(token_scopes):
"""Compare application scopes verbatim."""
return token_scopes
def has_permission(self, request, view):
"""Check the scope claim, then the grant recorded in the database."""
granted = super().has_permission(request, view)
required_scope = self.get_required_scope(view)
if granted and required_scope:
client_id = (request.auth or {}).get("client_id")
if not models.Application.has_active_scope(client_id, required_scope):
logger.warning(
"Application '%s' presented scope '%s' without a matching "
"grant in database",
client_id,
required_scope,
)
raise exceptions.PermissionDenied(
f"Application is not granted the required scope: {required_scope}"
)
return granted
class HasRequiredRoomScope(BaseScopePermission):
"""Permission class for Room-related operations."""
@@ -86,6 +146,14 @@ class HasRequiredRoomScope(BaseScopePermission):
}
class HasRequiredUserScope(ApplicationScopePermission):
"""Scope-based permissions for the external user endpoints."""
scope_map = {
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
+62 -2
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
@@ -27,6 +26,7 @@ from core import analytics, api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from core.services.transit_code import TransitCodeService
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -74,7 +74,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:
@@ -265,3 +265,63 @@ class RoomViewSet(
updated_fields=updated_fields,
previous_access_level=previous_values["access_level"],
)
class UserViewSet(viewsets.GenericViewSet):
"""Application-delegated API for user operations.
Provides JWT-authenticated access to user operations for external
applications acting on behalf of users. All operations are
scope-based. Meant to grow with the other user actions exposed to
third parties.
Supported operations:
- transit-code: Mint a single-use transit code for the delegated user
(requires 'users:session' scope)
"""
authentication_classes = [
authentication.ApplicationJWTAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
]
@decorators.action(
detail=False,
methods=["post"],
url_path="transit-code",
url_name="transit-code",
)
@FeatureFlag.require("user_access_token")
def generate_transit_code(self, request):
"""Mint a transit code for the delegated user.
Returns a short-lived, single-use opaque code to pass to an embedded
frontend (e.g. via a URL fragment when cookies are unavailable). The
frontend exchanges it once on
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
equivalent to session-cookie authentication and never exposed in a URL.
"""
if not request.auth or not request.auth.get("client_id"):
raise drf_exceptions.AuthenticationFailed("Invalid application token.")
client_id = request.auth["client_id"]
code = TransitCodeService().create_code(request.user, client_id=client_id)
# Log for auditing
logger.info(
"Transit code issued: user_id=%s, client_id=%s",
request.user.id,
client_id,
)
return drf_response.Response(
{
"transit_code": code,
"expires_in": settings.TRANSIT_CODE_TTL,
},
status=drf_status.HTTP_200_OK,
)
+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))
@@ -0,0 +1,94 @@
"""Purge inactive rooms."""
from datetime import timedelta
from itertools import batched
from logging import getLogger
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from core.models import Recording, RecordingStatusChoices, Room
logger = getLogger(__name__)
CHUNK_SIZE = 500
class Command(BaseCommand):
"""
Delete rooms that have not been started for ROOM_INACTIVITY_DELETION_DAYS days:
- rooms which were last started before that period
- rooms never started and created before that period
Rooms holding a saved recording that has not expired are kept.
"""
help = "Purge inactive rooms"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="List the rooms that would be purged without deleting them",
)
def handle(self, *args, **options):
"""Browse inactive rooms and delete them chunk by chunk."""
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
self.stdout.write(
"Purging inactive rooms is disabled "
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
)
return
now = timezone.now()
inactive_rooms = self.get_inactive_rooms(now)
inactive_count = inactive_rooms.count()
if not inactive_count:
self.stdout.write("No inactive room to purge.")
return
if options["dry_run"]:
self.stdout.write(
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
)
names = inactive_rooms.values_list("name", flat=True)
for name in names.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}")
return
purged_count = 0
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
for room_id, slug in chunk:
logger.info("Purging inactive room %s (%s)", room_id, slug)
_, deleted_by_model = inactive_rooms.filter(
pk__in=[room_id for room_id, _ in chunk]
).delete()
purged_count += deleted_by_model.get("core.Room", 0)
self.stdout.write(f"Purged {purged_count} inactive room(s).")
@staticmethod
def get_inactive_rooms(now):
"""Return the rooms inactive for too long that no recording protects."""
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
is_inactive = Q(last_started_at__lt=threshold) | Q(
last_started_at__isnull=True, created_at__lt=threshold
)
protected_recordings = Recording.objects.filter(
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
)
if settings.RECORDING_EXPIRATION_DAYS:
protected_recordings = protected_recordings.filter(
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
)
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
@@ -0,0 +1,18 @@
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0023_alter_recording_status'),
]
operations = [
migrations.AddField(
model_name='room',
name='last_started_at',
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, help_text='date and time at which the room was last started', null=True, verbose_name='last started at'),
preserve_default=False,
),
]
@@ -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
),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.2.14 on 2026-07-31 18:27
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0025_application_client_secret_sha256'),
]
operations = [
migrations.AlterField(
model_name='application',
name='scopes',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
),
]
+97 -9
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
@@ -87,6 +88,17 @@ class RecordingStatusChoices(models.TextChoices):
cls.FAILED_TO_STOP,
}
@classmethod
def saved_statuses(cls):
"""Return the statuses of a recording whose file users can access."""
return {
cls.NOTIFICATION_SUCCEEDED,
cls.SAVED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
}
class RecordingModeChoices(models.TextChoices):
"""Recording mode choices."""
@@ -426,6 +438,13 @@ class Room(Resource):
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
last_started_at = models.DateTimeField(
verbose_name=_("last started at"),
help_text=_("date and time at which the room was last started"),
blank=True,
null=True,
editable=False,
)
class Meta:
db_table = "meet_room"
@@ -682,12 +701,7 @@ class Recording(BaseModel):
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
return self.status in RecordingStatusChoices.saved_statuses()
@property
def extension(self):
@@ -786,6 +800,7 @@ class ApplicationScope(models.TextChoices):
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
USERS_SESSION = "users:session", _("Create user session tokens")
class Application(BaseModel):
@@ -811,6 +826,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,
@@ -826,14 +844,84 @@ 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."""
if not self.allowed_domains.exists():
allowed_domains = {d.domain for d in self.allowed_domains.all()}
if not allowed_domains:
return True # No domain restrictions
domain = get_domain_from_email(email)
return self.allowed_domains.filter(domain__iexact=domain).exists()
return bool(domain) and domain.lower() in allowed_domains
@classmethod
def has_active_scope(cls, client_id, scope) -> bool:
"""Check that an active application holds a scope."""
if not client_id or not scope:
return False
return cls.objects.filter(
client_id=client_id,
is_active=True,
scopes__contains=[scope],
).exists()
class ApplicationDomain(BaseModel):
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import get_language, gettext, gettext_noop, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from kombu.exceptions import OperationalError as KombuOperationalError
from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.tasks.email import send_email
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -68,10 +68,14 @@ class NotificationService:
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
Queue an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
this only does one DB query and enqueues one message per owner.
Returns True when every email was queued (or sent, if Celery is disabled).
"""
owner_accesses = (
@@ -88,10 +92,6 @@ class NotificationService:
return False
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}",
@@ -104,37 +104,22 @@ class NotificationService:
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
local_created_at = recording.created_at.astimezone(user.timezone)
try:
send_email.delay(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=[user.email],
language=user.language or get_language(),
context={
**context,
"recording_date": local_created_at.strftime("%Y-%m-%d"),
"recording_time": local_created_at.strftime("%H:%M"),
},
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
except (smtplib.SMTPException, KombuOperationalError) as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
+55 -16
View File
@@ -22,6 +22,46 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
def build_encoding_options(resolution, profile=None):
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
Single source of truth shared by the default encoding
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
persisted by the start-recording API, so both paths always produce the
same shape.
The profile-independent fields (audio bitrate, keyframe interval and the
pinned codec / frequency constants) are always included.
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
Framerate and bitrate are left to LiveKit only when the operator
declared no default profile at all.
"""
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
options: Dict[str, Any] = {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
if resolution:
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
resolution
]
options["width"] = resolution_config["width"]
options["height"] = resolution_config["height"]
if resolution and profile:
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
options["framerate"] = profile_config["fps"]
options["video_bitrate"] = profile_config["kbps"][resolution]
return options
@dataclass(frozen=True)
class WorkerServiceConfig:
"""Declare Worker Service common configurations"""
@@ -38,22 +78,16 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.")
# The default encoding is resolved from the default profile/resolution and
# applied to every recording that carries no per-recording encoding.
# When either default is missing, we leave this as None so LiveKit falls
# back to its built-in preset.
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
if resolution and profile:
encoding_options = build_encoding_options(resolution, profile)
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -78,7 +112,12 @@ class WorkerService(Protocol):
def __init__(self, config: WorkerServiceConfig):
"""Initialize the service with the given configuration."""
def start(self, room_id: str, recording_id: str) -> str:
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str:
"""Start a recording for a specified room."""
def stop(self, worker_id: str) -> str:
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
raise RecordingStartError()
room_name = str(recording.room.id)
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
try:
worker_id = self._worker_service.start(room_name, recording.id)
worker_id = self._worker_service.start(
room_name, recording.id, encoding_options=encoding_options
)
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
logger.exception(
"Failed to start recording for room %s: %s", recording.room.slug, e
+26 -16
View File
@@ -9,7 +9,7 @@ from livekit import api as livekit_api
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .exceptions import WorkerConnectionError, WorkerRequestError, WorkerResponseError
from .factories import WorkerServiceConfig
logger = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ class BaseEgressService:
def _get_filepath(self, filename: str, extension: str) -> str:
"""Construct the file path for a given filename and extension.
Unsecure method, doesn't handle paths robustly and securely.
Insecure method, doesn't handle paths robustly and securely.
"""
return f"{self._config.output_folder}/{filename}.{extension}"
@@ -108,28 +108,33 @@ class BaseEgressService:
self._log_egress_error(response, "failed to stop")
return "FAILED_TO_STOP"
def start(self, room_name, recording_id):
def start(self, room_name, recording_id, encoding_options=None):
"""Start the egress process for a recording (not implemented in the base class).
Each derived class must implement this method, providing the necessary parameters for
its specific egress type (e.g. audio_only, streaming output).
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
def _resolve_encoding_options(self, encoding_options):
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
``encoding_options`` is the per-recording dict persisted by the API in
``recording.options["encoding"]["resolved"]``; it falls back to the
default encoding carried by the service config.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
encoding_options = encoding_options or self._config.encoding_options
if not encoding_options:
return None
return livekit_api.EncodingOptions(**opts)
try:
return livekit_api.EncodingOptions(**encoding_options)
except (TypeError, ValueError) as e:
# Protobuf raises TypeError on a wrongly typed value (e.g. a float
# framerate) and ValueError on an unknown field or an out-of-range int.
raise WorkerRequestError(f"Invalid encoding options: {e}") from e
class VideoCompositeEgressService(BaseEgressService):
@@ -137,7 +142,7 @@ class VideoCompositeEgressService(BaseEgressService):
hrid = "video-recording-composite-livekit-egress"
def start(self, room_name, recording_id):
def start(self, room_name, recording_id, encoding_options=None):
"""Start the video composite egress process for a recording."""
# Save room's recording as a mp4 video file.
@@ -158,7 +163,7 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
advanced = self._resolve_encoding_options(encoding_options)
if advanced is not None:
request_kwargs["advanced"] = advanced
@@ -177,8 +182,13 @@ class AudioCompositeEgressService(BaseEgressService):
hrid = "audio-recording-composite-livekit-egress"
def start(self, room_name, recording_id):
"""Start the audio composite egress process for a recording."""
def start(self, room_name, recording_id, encoding_options=None):
"""Start the audio composite egress process for a recording.
``encoding_options`` is accepted for signature compatibility with the
WorkerService protocol but ignored: audio-only egress has no
encoding to configure.
"""
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
+13 -4
View File
@@ -8,6 +8,7 @@ from enum import Enum
from logging import getLogger
from django.conf import settings
from django.utils import timezone
from livekit import api
@@ -270,12 +271,20 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
room_updated_count = models.Room.objects.filter(pk=room_id).update(
last_started_at=timezone.now()
)
if not room_updated_count:
raise ActionFailedError(f"Room with ID {room_id} does not exist")
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
room = models.Room.objects.get(pk=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(
f"Room with ID {room_id} does not exist"
) from err
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
+48 -62
View File
@@ -131,23 +131,6 @@ class LobbyService:
if participant_ids:
self._redis().srem(self._get_index_key(room_id), *participant_ids)
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
@staticmethod
def prepare_response(response, participant_id):
"""Set participant cookie if needed."""
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
response.set_cookie(
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
samesite="Lax",
)
@staticmethod
def can_bypass_lobby(room, user, role) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly.
@@ -178,8 +161,9 @@ class LobbyService:
def request_entry(
self,
room: models.Room,
request,
user,
username: str,
participant_id: Optional[uuid.UUID] = None,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
@@ -194,52 +178,51 @@ class LobbyService:
5. If denied, do nothing.
"""
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
participant = None
if participant_id:
participant = self._get_participant(room.id, participant_id)
is_new_participant = participant is None
if is_new_participant:
participant = self._create_participant(username)
room_id = str(room.id)
user_role = room.get_role(request.user)
user_role = room.get_role(user)
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
if participant is None:
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
if self.can_bypass_lobby(room=room, user=user, role=user_role):
if not is_new_participant:
self.clear_participant_cache(room.id, participant.id)
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
user=user,
username=participant.username,
color=participant.color,
configuration=room.configuration,
participant_id=participant_id,
participant_id=participant.id,
role=user_role,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
if is_new_participant:
self._save_participant(room.id, participant)
self._notify_entry_request(room_id)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
self.refresh_waiting_status(room.id, participant.id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
user=user,
username=participant.username,
color=participant.color,
configuration=room.configuration,
participant_id=participant_id,
participant_id=participant.id,
role=user_role,
)
@@ -257,24 +240,35 @@ class LobbyService:
)
self._index_touch(room_id)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby."""
color = utils.generate_color(participant_id)
def _create_participant(self, username: str) -> LobbyParticipant:
"""Create a new waiting participant without persisting it.
Participant identifiers are minted here, server-side, exclusively.
"""
participant_id = str(uuid.uuid4())
participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color=color,
entered_at=timezone.now().isoformat(),
color=utils.generate_color(participant_id),
)
return participant
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
"""Persist a participant in the room's lobby."""
cache.set(
self._get_cache_key(room_id, participant.id),
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
self._index_add(room_id, participant.id)
def _notify_entry_request(self, room_id: str):
"""Notify room participants of a new entry request."""
try:
utils.notify_participants(
room_name=str(room_id),
room_name=room_id,
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
@@ -283,16 +277,6 @@ class LobbyService:
# If room not created yet, there is no participants to notify
logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
self._index_add(room_id, participant_id)
return participant
def _get_participant(
self, room_id: UUID, participant_id: str
) -> Optional[LobbyParticipant]:
@@ -353,7 +337,7 @@ class LobbyService:
room_id: UUID,
participant_id: str,
allow_entry: bool,
) -> None:
) -> LobbyParticipant:
"""Handle decision on participant entry.
Updates participant status based on allow_entry:
@@ -371,7 +355,7 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT,
}
self._update_participant_status(room_id, participant_id, **decision)
return self._update_participant_status(room_id, participant_id, **decision)
def _update_participant_status(
self,
@@ -379,7 +363,7 @@ class LobbyService:
participant_id: str,
status: LobbyParticipantStatus,
timeout: int,
) -> None:
) -> LobbyParticipant:
"""Update participant status with appropriate timeout."""
cache_key = self._get_cache_key(room_id, participant_id)
@@ -402,6 +386,8 @@ class LobbyService:
cache.set(cache_key, participant.to_dict(), timeout=timeout)
self._index_touch(room_id)
return participant
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
-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()
+74
View File
@@ -0,0 +1,74 @@
"""Service handling the lifecycle of transit codes.
A transit code is an opaque, cryptographically random, single-use code
handed to an embedded frontend (through a URL fragment) so it can obtain a
user access token on the core API without a session cookie. The code
carries no information by itself: everything it references (user, client)
is stored server-side in the cache, and consumed atomically on exchange.
"""
import hashlib
import secrets
from django.conf import settings
from django.core.cache import cache
class TransitCodeService:
"""Create and consume single-use transit codes."""
@staticmethod
def _cache_key(code):
"""Build the cache key for a code.
The code is hashed so that a dump of the cache never reveals
directly usable codes.
"""
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
def create_code(self, user, client_id):
"""Generate a transit code for a user, and store it.
The code expires after TRANSIT_CODE_TTL seconds.
Returns:
str: The opaque code to hand to the client.
"""
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
# entropy: unguessable and safe to transit through a URL fragment.
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
cache.set(
self._cache_key(code),
{
"user_id": str(user.id),
"client_id": client_id,
},
timeout=settings.TRANSIT_CODE_TTL,
)
return code
def consume_code(self, code):
"""Consume a transit code, enforcing single use.
The code is deleted from the cache upon consumption. `cache.delete`
returns whether a key was actually deleted, so if two requests race
on the same code, only one of them wins.
Returns:
dict | None: The data stored at creation time ('user_id',
'client_id'), or None if the code is unknown, expired or
already consumed.
"""
if not code:
return None
key = self._cache_key(code)
data = cache.get(key)
if data is None or not cache.delete(key):
return None
return data
+2
View File
@@ -1,9 +1,11 @@
"""Celery tasks for the core app."""
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.email import send_email
from core.tasks.file import process_file_deletion
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
"send_email",
)
+74
View File
@@ -0,0 +1,74 @@
"""Generic tasks to send transactional emails."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from core.tasks._task import task
logger = logging.getLogger(__name__)
def get_base_email_context() -> dict:
"""Context shared by every email template (branding, support, domain)."""
return {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
}
@task(
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def send_email(
*,
template: str,
subject: str,
recipients: list[str],
context: dict | None = None,
language: str | None = None,
):
"""Render a mail template and send it.
Generic on purpose: any feature can reuse it with its own template and context.
Args:
template: Template base name, e.g. "screen_recording". Both
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
call site; it is translated here, in ``language``.
recipients: Email addresses, all receiving the same rendered message.
context: Template context. Must be JSON-serializable (str, int, list, dict…):
pre-format dates, cast UUIDs to str. Merged over the base branding context.
language: Language to render the email in. Defaults to the active language.
Raises:
smtplib.SMTPException: after retries are exhausted (or immediately when
Celery is disabled and the call runs synchronously).
"""
full_context = {**get_base_email_context(), **(context or {})}
with override(language or get_language()):
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
translated_subject = gettext(subject)
send_mail(
translated_subject,
msg_plain,
settings.EMAIL_FROM,
recipients,
html_message=msg_html,
fail_silently=False,
)
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
@@ -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"]}
)
@@ -0,0 +1,239 @@
"""Tests for the purge_inactive_rooms management command."""
import logging
from datetime import timedelta
from io import StringIO
from unittest import mock
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
BEFORE_PERIOD = timedelta(days=366)
WITHIN_PERIOD = timedelta(days=364)
@pytest.fixture(name="purge_enabled", autouse=True)
def fixture_purge_enabled(settings):
"""Enable the purge of the rooms inactive for a year."""
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
settings.RECORDING_EXPIRATION_DAYS = 30
def create_at(date, factory, **kwargs):
"""Build an object with the factory as if it was created at the given date."""
with mock.patch("django.utils.timezone.now", return_value=date):
return factory(**kwargs)
def call_purge(*args):
"""Run the purge command and return what it wrote on stdout."""
out = StringIO()
call_command("purge_inactive_rooms", *args, stdout=out)
return out.getvalue()
def room_exists(room):
"""Tell whether the room is still in database."""
return models.Room.objects.filter(pk=room.pk).exists()
def test_purge_inactive_rooms_disabled(settings):
"""Should delete nothing when no inactivity period is configured."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
assert "disabled" in call_purge()
assert room_exists(room)
def test_purge_inactive_rooms_without_recording_expiration(settings):
"""Should purge when recordings never expire, keeping rooms with a saved one."""
settings.RECORDING_EXPIRATION_DAYS = None
long_ago = timezone.now() - BEFORE_PERIOD
room = create_at(long_ago, factories.RoomFactory)
room_with_recording = create_at(long_ago, factories.RoomFactory)
create_at(
long_ago,
factories.RecordingFactory,
room=room_with_recording,
status=models.RecordingStatusChoices.SAVED,
)
assert call_purge() == "Purged 1 inactive room(s).\n"
assert not room_exists(room)
assert room_exists(room_with_recording)
def test_purge_inactive_rooms_without_recording_expiration_not_saved(settings):
"""Should delete a room whose recordings were never saved when none expire."""
settings.RECORDING_EXPIRATION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
call_purge()
assert not room_exists(room)
def test_purge_inactive_rooms_started_before_period(caplog):
"""Should delete a room that was last started before the inactivity period."""
now = timezone.now()
room = create_at(
now - timedelta(days=800),
factories.RoomFactory,
last_started_at=now - BEFORE_PERIOD,
)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 inactive room(s).\n"
assert not room_exists(room)
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
def test_purge_inactive_rooms_never_started_created_before_period():
"""Should delete a room that was never started and created before the period."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
call_purge()
assert not room_exists(room)
def test_purge_inactive_rooms_started_within_period():
"""Should keep a room created long ago that was started within the period."""
now = timezone.now()
room = create_at(
now - timedelta(days=800),
factories.RoomFactory,
last_started_at=now - WITHIN_PERIOD,
)
assert call_purge() == "No inactive room to purge.\n"
assert room_exists(room)
def test_purge_inactive_rooms_never_started_created_within_period():
"""Should keep a room that was never started but created within the period."""
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
assert call_purge() == "No inactive room to purge.\n"
assert room_exists(room)
@pytest.mark.parametrize(
"status", sorted(models.RecordingStatusChoices.saved_statuses())
)
def test_purge_inactive_rooms_recording_not_expired(settings, status):
"""Should keep a room holding a saved recording that has not expired yet."""
settings.RECORDING_EXPIRATION_DAYS = 400
long_ago = timezone.now() - BEFORE_PERIOD
room = create_at(long_ago, factories.RoomFactory)
create_at(long_ago, factories.RecordingFactory, room=room, status=status)
call_purge()
assert room_exists(room)
def test_purge_inactive_rooms_recording_expired(settings):
"""Should delete a room along with its recordings when they all have expired."""
settings.RECORDING_EXPIRATION_DAYS = 30
long_ago = timezone.now() - BEFORE_PERIOD
room = create_at(long_ago, factories.RoomFactory)
recording = create_at(
long_ago,
factories.RecordingFactory,
room=room,
status=models.RecordingStatusChoices.SAVED,
)
call_purge()
assert not room_exists(room)
assert not models.Recording.objects.filter(pk=recording.pk).exists()
@pytest.mark.parametrize(
"status",
[
status
for status in models.RecordingStatusChoices
if status not in models.RecordingStatusChoices.saved_statuses()
],
)
def test_purge_inactive_rooms_recording_not_saved(status):
"""Should delete a room whose recordings were never saved, even unexpired."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
factories.RecordingFactory(room=room, status=status)
call_purge()
assert not room_exists(room)
def test_purge_inactive_rooms_recording_saved_among_others():
"""Should keep a room holding a saved recording next to a failed one."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
call_purge()
assert room_exists(room)
def test_purge_inactive_rooms_deletes_accesses_and_resource():
"""Should delete the last owner access and the resource of a purged room."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
access = factories.UserResourceAccessFactory(
resource=room, role=models.RoleChoices.OWNER
)
call_purge()
assert not room_exists(room)
assert not models.Resource.objects.filter(pk=room.pk).exists()
assert not models.ResourceAccess.objects.filter(pk=access.pk).exists()
assert models.User.objects.filter(pk=access.user.pk).exists()
def test_purge_inactive_rooms_dry_run():
"""Should list the inactive rooms by name without deleting them on a dry run."""
long_ago = timezone.now() - BEFORE_PERIOD
rooms = [
create_at(long_ago, factories.RoomFactory, name=name)
for name in ("Alpha room", "Beta room")
]
factories.RoomFactory(name="Recent room")
assert call_purge("--dry-run") == (
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
)
assert all(room_exists(room) for room in rooms)
def test_purge_inactive_rooms_several_chunks():
"""Should delete every inactive room when they span several chunks."""
long_ago = timezone.now() - BEFORE_PERIOD
rooms = [create_at(long_ago, factories.RoomFactory) for _ in range(5)]
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
output = call_purge()
assert output == "Purged 5 inactive room(s).\n"
assert not any(room_exists(room) for room in rooms)
@@ -117,14 +117,14 @@ def test_api_files_create_file_authenticated_success():
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.netloc in ["garage:9000", "localhost:9000"]
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
assert query_params.pop("X-Amz-Credential") == [
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
f"meet-access-key/{now.strftime('%Y%m%d')}/local/s3/aws4_request"
]
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
assert query_params.pop("X-Amz-Expires") == ["60"]
@@ -27,7 +27,7 @@ def test_api_files_list_anonymous_not_allowed():
def test_api_files_list_authentificated_user_allowed():
"""
Authentificated users should be allowed to list files
Authenticated users should be allowed to list files
"""
user = factories.UserFactory()
client = APIClient()
@@ -12,6 +12,7 @@ from unittest import mock
from django.contrib.sites.models import Site
import pytest
from kombu.exceptions import OperationalError as KombuOperationalError
from core import factories, models
from core.analytics import UserFeatureFlag
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
with mock.patch("core.tasks.email.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
"core.tasks.email.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert "notification could not be sent:" in caplog.text
def test_notify_user_by_email_queues_generic_task(settings):
"""One generic send_email task is queued per owner, with serializable args."""
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_EXPIRATION_DAYS = 7
recording = factories.RecordingFactory(room__name="Conference Room A")
recording.created_at = datetime.datetime(
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
)
user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=user
)
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_delay.assert_called_once_with(
template="screen_recording",
subject="Your recording is ready",
recipients=["franc@test.com"],
language="fr-fr",
context={
"room_name": "Conference Room A",
"recording_expiration_days": 7,
"link": f"https://acme.com/recordings/{recording.id}",
"recording_date": "2023-05-15",
"recording_time": "16:30",
},
)
# Celery's JSON serializer must accept every argument
json.dumps(mock_delay.call_args.kwargs)
def test_notify_user_by_email_broker_unavailable(caplog):
"""A broker outage is reported as a failure without crashing the caller."""
recording = factories.RecordingFactory()
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
with mock.patch(
"core.recording.event.notification.send_email.delay",
side_effect=KombuOperationalError("broker down"),
):
result = notification_service._notify_user_by_email(recording)
assert result is False
assert "notification could not be sent: broker down" in caplog.text
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
@@ -0,0 +1,184 @@
"""Tests for the per-recording encoding resolution in BaseEgressService."""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
from unittest.mock import Mock
from django.conf import settings
from django.test import override_settings
import pytest
from livekit import api as livekit_api
from pydantic import ValidationError as PydanticValidationError
from core.api.serializers import EncodingConfig
from core.recording.worker.exceptions import WorkerRequestError
from core.recording.worker.factories import build_encoding_options
from core.recording.worker.services import VideoCompositeEgressService
def make_config():
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
config = Mock()
config.bucket_args = {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
}
config.encoding_options = None
return config
@pytest.fixture
def service():
"""Return a VideoCompositeEgressService with mocked handle_request."""
svc = VideoCompositeEgressService(make_config())
svc._handle_request = Mock()
return svc
# --- build_encoding_options ---
def test_build_options_without_profile_uses_default_profile():
"""A resolution-only config should fall back to the default profile.
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
defaults. The profile-independent fields (audio bitrate, keyframe interval,
codec/frequency pins) are always present, matching the default encoding.
"""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("540p")
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 960,
"height": 540,
"framerate": default_profile["fps"],
"video_bitrate": default_profile["kbps"]["540p"],
}
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_build_options_omits_profile_fields_without_default_profile():
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
resolved = build_encoding_options("720p", None)
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 1280,
"height": 720,
}
assert "framerate" not in resolved
assert "video_bitrate" not in resolved
def test_encoding_config_requires_resolution():
"""A profile-only or empty encoding config should be rejected at validation."""
with pytest.raises(PydanticValidationError):
EncodingConfig(profile="mixed")
with pytest.raises(PydanticValidationError):
EncodingConfig()
# --- _resolve_encoding_options ---
@pytest.mark.parametrize("encoding_options", [None, {}])
def test_resolve_options_returns_none_when_empty(service, encoding_options):
"""Resolver should return None when the resolved dict is empty or missing."""
assert service._resolve_encoding_options(encoding_options) is None
@pytest.mark.parametrize(
"encoding_options",
[
{"framerate": 29.97},
{"width": "1280"},
{"unknown_field": 1},
],
)
def test_resolve_options_invalid_raises_worker_request_error(service, encoding_options):
"""Malformed encoding options should surface as a WorkerRequestError."""
with pytest.raises(WorkerRequestError):
service._resolve_encoding_options(encoding_options)
@pytest.mark.parametrize(
"resolution",
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
)
@pytest.mark.parametrize(
"profile",
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
)
def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings."""
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
expected_width = resolution_config["width"]
expected_height = resolution_config["height"]
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
expected_fps = profile_config["fps"]
expected_bitrate = profile_config["kbps"][resolution]
resolved = build_encoding_options(resolution, profile)
result = service._resolve_encoding_options(resolved)
assert result.width == expected_width
assert result.height == expected_height
assert result.framerate == expected_fps
assert result.video_bitrate == expected_bitrate
# Profile-independent fields match the default encoding, never dropped.
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
assert result.audio_codec == livekit_api.AudioCodec.AAC
assert result.audio_frequency == 48000
def test_resolve_options_none_profile_uses_default_profile(service):
"""A missing profile should resolve to the default profile's fps/bitrate."""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == default_profile["fps"]
assert result.video_bitrate == default_profile["kbps"]["720p"]
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_resolve_options_passes_zero_when_no_default_profile(service):
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
The pinned codec / audio fields are still applied.
"""
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == 0
assert result.video_bitrate == 0
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
assert result.audio_codec == livekit_api.AudioCodec.AAC
@@ -40,6 +40,16 @@ def test_settings():
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
"AWS_S3_REGION_NAME": "test-region",
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
"720p": {"width": 1280, "height": 720}
},
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
"full": {"fps": 30, "kbps": {"720p": 3000}}
},
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
}
# Use override_settings to properly patch Django settings
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
"bucket": "test-bucket",
"force_path_style": True,
}
# Encoding override is opt-in; disabled by default.
assert default_config.encoding_options is None
# The default encoding is always resolved from the default profile/resolution.
assert default_config.encoding_options == {
"width": 1280,
"height": 720,
"framerate": 30,
"video_bitrate": 3000,
"audio_bitrate": 128,
"key_frame_interval": 4.0,
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
"audio_codec": livekit_api_codec.AudioCodec.AAC,
"audio_frequency": 48000,
}
def test_config_immutability(default_config):
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path"
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280,
RECORDING_ENCODING_HEIGHT=720,
RECORDING_ENCODING_FRAMERATE=15,
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
RECORDING_ENCODING_DEFAULT_PROFILE="low",
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
)
def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
def test_config_encoding_options_default(custom_encoding_enabled):
"""The default encoding is always resolved from the default profile/resolution.
The dict mixes operator-tunable values from settings with pinned codec /
frequency constants, so the services layer can simply unpack it.
The default fallback resolves the default profile/resolution and mixes those
operator-tunable values with pinned codec / frequency constants. This works
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
per-recording API, so both toggle states produce the same default.
"""
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == {
"width": 1280,
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
}
@pytest.mark.parametrize(
("default_resolution", "default_profile"),
[("", "full"), ("720p", ""), ("", "")],
)
def test_config_encoding_options_none_when_default_missing(
test_settings, default_resolution, default_profile
):
"""A missing default resolution/profile leaves encoding_options None.
The service then omits the `advanced` field so LiveKit uses its built-in preset.
"""
with override_settings(
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
):
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options is None
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
# Verify worker service call
expected_room_name = str(mock_recording.room.id)
mock_worker_service.start.assert_called_once_with(
expected_room_name, mock_recording.id
expected_room_name, mock_recording.id, encoding_options=None
)
# Verify recording updates
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_passes_resolved_encoding(
mock_update_metadata, mediator, mock_worker_service
):
"""The resolved encoding persisted in recording.options reaches the worker."""
mock_worker_service.start.return_value = "test-worker-123"
resolved = {
"key_frame_interval": 4.0,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
}
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
options={
"encoding": {
"resolution": "720p",
"profile": "talking_heads",
"resolved": resolved,
}
},
)
mediator.start(mock_recording)
mock_worker_service.start.assert_called_once_with(
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@@ -9,8 +9,13 @@ from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...api.throttling import (
RoomCreationDailyUserRateThrottle,
RoomCreationUserRateThrottle,
)
from ...factories import RoomFactory, UserFactory
from ...models import Room, RoomAccessLevel
from ..utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -312,3 +317,158 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@pytest.fixture
def room_creation_throttle(monkeypatch):
"""Lower the room creation rate for the duration of a test."""
monkeypatch.setitem(
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"], "room_creation", "2/minute"
)
def test_api_rooms_create_throttled(room_creation_throttle):
"""Excess requests are rejected and create no room."""
client = APIClient()
client.force_login(UserFactory())
for index in range(2):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
assert 0 < int(response["Retry-After"]) <= 60
assert Room.objects.count() == 2
def test_api_rooms_create_throttle_per_user(room_creation_throttle):
"""Users sharing an IP have independent creation limits."""
client = APIClient()
client.force_login(UserFactory())
for index in range(2):
response = client.post("/api/v1.0/rooms/", {"name": f"First user room {index}"})
assert response.status_code == 201
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
client.force_login(UserFactory())
response = client.post("/api/v1.0/rooms/", {"name": "Second user room"})
assert response.status_code == 201
def test_api_rooms_create_throttle_does_not_limit_other_actions(room_creation_throttle):
"""Exhausting creation capacity leaves listing and updating available."""
client = APIClient()
client.force_login(UserFactory())
for index in range(2):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
room_id = response.json()["id"]
assert client.post("/api/v1.0/rooms/", {"name": "Blocked room"}).status_code == 429
assert client.get("/api/v1.0/rooms/").status_code == 200
assert (
client.patch(
f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed room"}
).status_code
== 200
)
@pytest.fixture
def daily_room_creation_throttle(monkeypatch):
"""Use a tiny daily cap, a loose burst limit and a controllable clock.
Rates are patched with monkeypatch.setitem so they are restored after the
test. Returns a one-item list holding the current fake timestamp.
"""
rates = RoomCreationDailyUserRateThrottle.THROTTLE_RATES
monkeypatch.setitem(rates, "room_creation", "100/minute")
monkeypatch.setitem(rates, "room_creation_daily", "3/day")
now = [1_000_000.0]
monkeypatch.setattr(RoomCreationUserRateThrottle, "timer", lambda self: now[0])
return now
def test_api_rooms_create_daily_throttled(daily_room_creation_throttle):
"""The daily cap still applies once the short-term window has elapsed."""
now = daily_room_creation_throttle
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
now[0] += 120 # Spread creations beyond the short-term window.
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
assert int(response["Retry-After"]) > 60
assert Room.objects.count() == 3
def test_api_rooms_create_daily_throttle_resets(daily_room_creation_throttle):
"""Room creation is allowed again once a day has passed."""
now = daily_room_creation_throttle
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
response = client.post("/api/v1.0/rooms/", {"name": "Blocked room"})
assert response.status_code == 429
now[0] += 24 * 60 * 60 + 1
response = client.post("/api/v1.0/rooms/", {"name": "Next day room"})
assert response.status_code == 201
def test_api_rooms_create_daily_throttle_per_user(daily_room_creation_throttle):
"""Each user has its own daily cap."""
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
assert client.post("/api/v1.0/rooms/", {"name": "Blocked"}).status_code == 429
client.force_login(UserFactory())
response = client.post("/api/v1.0/rooms/", {"name": "Other user room"})
assert response.status_code == 201
def test_api_rooms_create_daily_throttle_does_not_limit_other_actions(
daily_room_creation_throttle,
):
"""Reaching the daily cap leaves listing and updating available."""
client = APIClient()
client.force_login(UserFactory())
for index in range(3):
response = client.post("/api/v1.0/rooms/", {"name": f"Room {index}"})
assert response.status_code == 201
room_id = response.json()["id"]
assert client.post("/api/v1.0/rooms/", {"name": "Blocked"}).status_code == 429
assert client.get("/api/v1.0/rooms/").status_code == 200
response = client.patch(f"/api/v1.0/rooms/{room_id}/", {"name": "Renamed"})
assert response.status_code == 200
def test_api_rooms_create_authenticated_with_user_access_token():
"""A user access token should create a room exactly like a session would."""
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
assert response.status_code == 201
room = Room.objects.get()
assert room.accesses.filter(role="owner", user=user).exists()
@@ -10,6 +10,7 @@ from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ..utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -156,3 +157,18 @@ def test_api_rooms_list_pagination_page_size():
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None
def test_api_rooms_list_authenticated_with_user_access_token():
"""A user access token should list rooms exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
RoomFactory() # another user's room, not listed
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
@@ -6,6 +6,7 @@ Test rooms API endpoints in the Meet core app: lobby functionality.
import uuid
from unittest import mock
from django.core import signing
from django.core.cache import cache
import pytest
@@ -15,13 +16,16 @@ from rest_framework.test import APIClient
from ... import utils
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.lobby import (
LobbyService,
)
from ...services.lobby import LobbyService
pytestmark = pytest.mark.django_db
def _lobby_signer():
"""Use the polling credential's dedicated signing namespace."""
return signing.Signer(salt="core.lobby.participant")
# Tests for request_entry endpoint
@@ -31,7 +35,6 @@ def test_request_entry_anonymous(settings):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -49,11 +52,10 @@ def test_request_entry_anonymous(settings):
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# The participant identifier is returned in the response body; no
# cookie is involved anymore
assert not response.cookies
participant_id = response.json()["id"]
# Verify response content matches expected structure and values
assert response.json() == {
@@ -70,7 +72,8 @@ def test_request_entry_anonymous(settings):
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
raw_id = _lobby_signer().unsign(participant_id)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
assert participant_data.get("username") == "test_user"
@@ -82,7 +85,6 @@ def test_request_entry_authenticated_user(settings):
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -100,11 +102,10 @@ def test_request_entry_authenticated_user(settings):
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# The participant identifier is returned in the response body; no
# cookie is involved anymore
assert not response.cookies
participant_id = response.json()["id"]
# Verify response content matches expected structure and values
assert response.json() == {
@@ -121,7 +122,8 @@ def test_request_entry_authenticated_user(settings):
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
raw_id = _lobby_signer().unsign(participant_id)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
assert participant_data.get("username") == "test_user"
@@ -133,7 +135,6 @@ def test_request_entry_with_existing_participants(settings):
client = APIClient()
# Configure test settings for cookies and cache
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add two participants already waiting in the lobby
@@ -176,11 +177,10 @@ def test_request_entry_with_existing_participants(settings):
# Verify successful response
assert response.status_code == 200
# Verify the lobby cookie was properly set for the new participant
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# The participant identifier is returned in the response body; no
# cookie is involved anymore
assert not response.cookies
participant_id = response.json()["id"]
# Verify response content matches expected structure and values
assert response.json() == {
@@ -197,7 +197,8 @@ def test_request_entry_with_existing_participants(settings):
assert len(lobby_keys) == 3
# Verify the new participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
raw_id = _lobby_signer().unsign(participant_id)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
assert participant_data.get("username") == "test_user"
@@ -207,7 +208,6 @@ def test_request_entry_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -216,61 +216,8 @@ def test_request_entry_public_room(settings):
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
mock.patch(
"core.services.lobby.uuid.uuid4",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
),
mock.patch.object(
@@ -285,14 +232,9 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
@@ -300,10 +242,55 @@ def test_request_entry_authenticated_user_public_room(settings):
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not LobbyService()._index_members(room.id)
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch(
"core.services.lobby.uuid.uuid4",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify response content matches expected structure and values
assert response.json() == {
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
}
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not LobbyService()._index_members(room.id)
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_waiting_participant_public_room(settings):
@@ -311,7 +298,6 @@ def test_request_entry_waiting_participant_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add a waiting participant to the room's lobby cache
@@ -326,9 +312,9 @@ def test_request_entry_waiting_participant_public_room(settings):
},
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
LobbyService()._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
# Simulate a returning participant echoing its identifier
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
@@ -337,19 +323,19 @@ def test_request_entry_waiting_participant_public_room(settings):
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "user1"},
{
"username": "user1",
"participant_id": _lobby_signer().sign(
"2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
),
},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -357,9 +343,8 @@ def test_request_entry_waiting_participant_public_room(settings):
"livekit": {"token": "test-token"},
}
# Verify participant remains in the lobby cache after acceptance
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
assert not cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not LobbyService()._index_members(room.id)
def test_request_entry_invalid_data():
@@ -662,15 +647,14 @@ def test_list_waiting_participants_empty(settings):
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_without_cookie(
def test_request_entry_throttling_anonymous_unidentified(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie should not be throttled."""
"""Requests without a participant identifier should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post(
@@ -679,9 +663,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
)
assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
@@ -695,34 +676,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_with_cookie(
def test_request_entry_throttling_anonymous_identified(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
"""Identified requests should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
participant_id = _lobby_signer().sign(str(uuid.uuid4()))
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 429
@@ -741,7 +720,6 @@ def test_request_entry_throttling_authenticated_user(
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post(
@@ -762,3 +740,237 @@ def test_request_entry_throttling_authenticated_user(
)
assert response.status_code == 429
def test_request_entry_with_participant_id(settings):
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
participant_id = response.json()["id"]
# Echoing the identifier must be recognized as the same
# participant: no duplicate in the lobby
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] == participant_id
assert response.json()["status"] == "waiting"
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
def test_request_entry_unknown_participant_id_not_seeded(settings):
"""A valid signed credential with no cached record creates a new participant."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
forged_id = str(uuid.uuid4())
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{
"username": "test_user",
"participant_id": _lobby_signer().sign(forged_id),
},
)
assert response.status_code == 200
assert _lobby_signer().unsign(response.json()["id"]) != forged_id
# Nothing was stored under the forged identifier
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
def test_request_entry_participant_id_bound_to_room(settings):
"""An identifier minted for one room must not be honored in another."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
participant_id = response.json()["id"]
response = client.post(
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] != participant_id
def test_request_entry_legacy_cookie_ignored():
"""The retired cookie channel must not be honored anymore."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
legacy_participant_id = str(uuid.uuid4())
client.cookies["lobbyParticipantId"] = legacy_participant_id
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
returned_id = response.json()["id"]
assert returned_id != legacy_participant_id
uuid.UUID(_lobby_signer().unsign(returned_id))
def test_request_entry_malformed_participant_id(settings):
"""A malformed polling credential is rejected with a 400."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": "../../../evil-key"},
)
assert response.status_code == 400
assert "participant_id" in response.json()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(utils, "generate_livekit_config")
def test_request_entry_rejects_unsigned_id(generate_config, _notify):
"""Knowing the public UUID must not grant admission."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
public_id = _lobby_signer().unsign(response.json()["id"])
LobbyService().handle_participant_entry(room.id, public_id, True)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Impersonator", "participant_id": public_id},
)
assert response.status_code == 400
assert "participant_id" in response.json()
generate_config.assert_not_called()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(utils, "generate_livekit_config")
def test_request_entry_rejects_tampered_credential(generate_config, _notify):
"""Modifying a signed credential must invalidate it."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
credential = response.json()["id"]
public_id = _lobby_signer().unsign(credential)
LobbyService().handle_participant_entry(room.id, public_id, True)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Impersonator", "participant_id": credential + "x"},
)
assert response.status_code == 400
assert "participant_id" in response.json()
generate_config.assert_not_called()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(utils, "generate_livekit_config")
def test_request_entry_rejects_wrong_signing_secret(generate_config, _notify):
"""A credential signed with another secret must not grant admission."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
public_id = _lobby_signer().unsign(response.json()["id"])
LobbyService().handle_participant_entry(room.id, public_id, True)
forged = signing.Signer(
key="incorrect-test-signing-secret",
salt="core.lobby.participant",
fallback_keys=[],
).sign(public_id)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Impersonator", "participant_id": forged},
)
assert response.status_code == 400
assert "participant_id" in response.json()
generate_config.assert_not_called()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_accepts_signed_credential(generate_config, _notify):
"""The signed credential grants admission using the public LiveKit UUID."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
credential = response.json()["id"]
public_id = _lobby_signer().unsign(credential)
assert credential != public_id
LobbyService().handle_participant_entry(room.id, public_id, True)
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Guest", "participant_id": credential},
)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
assert response.json()["id"] == credential
assert response.json()["livekit"] == {"token": "test-token"}
generate_config.assert_called_once()
assert generate_config.call_args.kwargs["participant_id"] == public_id
@@ -19,8 +19,13 @@ from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
from core.factories import (
RoomFactory,
UserFactory,
UserResourceAccessFactory,
)
from core.services.lobby import LobbyParticipant, LobbyParticipantStatus, LobbyService
from core.tests.utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -87,7 +92,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -113,7 +118,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -153,7 +158,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -300,7 +305,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -330,7 +335,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -361,7 +366,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -381,7 +386,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -412,7 +417,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -440,7 +445,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -458,7 +463,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="an error occured", code="not_found", status=500
msg="an error occurred", code="not_found", status=500
)
user = AnonymousUser()
@@ -469,7 +474,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -849,7 +854,16 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
participant_identity = str(uuid4())
# Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe")
LobbyService()._save_participant(
room.id,
LobbyParticipant(
id=participant_identity,
username="John doe",
status=LobbyParticipantStatus.WAITING,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
)
# Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True)
@@ -1020,3 +1034,119 @@ def test_remove_participant_not_found(mock_livekit_client):
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_bearer_scheme_defers_to_next_authentication(
mock_livekit_client,
):
"""Should defer a "Bearer" header to the next authentication backend.
The LiveKit backend only claims the "X-LiveKit-Token" scheme. Any other
scheme must be left untouched so the backends declared after it get a
chance to authenticate the request.
"""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.get_participant.assert_not_called()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_bearer_scheme_defers_role_permissions_still_apply(
mock_livekit_client,
):
"""Should still enforce room privileges once another backend authenticated."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = UserFactory() # no UserResourceAccess for this room
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_unknown_scheme_defers_and_stays_anonymous(
mock_livekit_client,
):
"""Should leave the request unauthenticated when no backend claims the scheme."""
client = APIClient()
room = RoomFactory()
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_scheme_is_case_insensitive(mock_livekit_client):
"""Should claim the LiveKit scheme whatever its casing, and not defer it."""
client = APIClient()
room = RoomFactory()
token = utils.generate_token(str(room.id), AnonymousUser())
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_scheme_malformed_header_is_rejected(
mock_livekit_client,
):
"""Should reject a malformed header once the LiveKit scheme is claimed."""
client = APIClient()
room = RoomFactory()
token = utils.generate_token(str(room.id), AnonymousUser())
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "Authorization header must be: X-LiveKit-Token <token>"
}
mock_livekit_client.room.mute_published_track.assert_not_called()
@@ -17,7 +17,12 @@ from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory
from core.factories import (
RoomFactory,
UserFactory,
UserResourceAccessFactory,
)
from core.tests.utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -69,7 +74,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -84,7 +92,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": False},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -101,7 +112,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -117,7 +131,10 @@ def test_toggle_hand_identity_derived_from_token(
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -128,7 +145,9 @@ def test_toggle_hand_missing_raised_field(room, token):
"""Test toggle hand with missing raised field returns 400."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "raised" in response.data
@@ -142,7 +161,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
url,
{"raised": "not-a-boolean"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -166,7 +185,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -181,7 +203,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -200,7 +225,7 @@ def test_toggle_hand_raise_success_anonymous(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -220,7 +245,7 @@ def test_toggle_hand_lower_success_anonymous(
url,
{"raised": False},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -240,7 +265,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -257,7 +282,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -272,7 +300,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "Jane Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -286,7 +317,10 @@ def test_rename_participant_uses_identity_from_token(
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -298,7 +332,7 @@ def test_rename_participant_empty_name(room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -309,7 +343,9 @@ def test_rename_participant_missing_name(room, token):
"""Test rename with missing name field returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
@@ -320,7 +356,10 @@ def test_rename_participant_name_too_long(room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "a" * 256},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -348,7 +387,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -363,7 +402,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -385,7 +427,10 @@ def test_rename_participant_forbidden_when_display_name_edit_disabled(
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": name},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -404,7 +449,10 @@ def test_rename_participant_allowed_when_display_name_edit_enabled(
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -426,7 +474,7 @@ def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -443,7 +491,7 @@ def test_rename_participant_success_anonymous(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -463,7 +511,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -480,7 +528,7 @@ def test_rename_participant_sets_correct_name_anonymous(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -497,7 +545,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -523,7 +571,7 @@ def test_toggle_hand_expired_token(room, expired_token):
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -537,7 +585,7 @@ def test_rename_participant_expired_token(room, expired_token):
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -551,7 +599,7 @@ def test_toggle_hand_malformed_token(room):
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -565,7 +613,10 @@ def test_toggle_hand_room_not_found(user):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -580,7 +631,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -597,7 +651,7 @@ def test_rename_participant_malformed_token(room):
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -611,7 +665,10 @@ def test_rename_participant_room_not_found(user):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -626,10 +683,188 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
@pytest.fixture
def user_access_token(user):
"""Generate a valid user access JWT, sent with the "X-LiveKit-Token" scheme."""
return generate_user_access_token(user)
def test_toggle_hand_bearer_scheme_defers_to_next_authentication(
mock_livekit_client, room, user, user_access_token
):
"""Test toggle hand defers a "Bearer" header instead of failing on it."""
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"detail": "Authentication credentials were not provided."}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_bearer_scheme_defers_to_next_authentication(
mock_livekit_client, room, user, user_access_token
):
"""Test rename defers a "Bearer" header instead of failing on it."""
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"detail": "Authentication credentials were not provided."}
mock_livekit_client.room.update_participant.assert_not_called()
def test_toggle_hand_unknown_scheme_defers(mock_livekit_client, room):
"""Test toggle hand defers a scheme no backend recognizes."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"detail": "Authentication credentials were not provided."}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_unknown_scheme_defers(mock_livekit_client, room):
"""Test rename defers a scheme no backend recognizes."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"detail": "Authentication credentials were not provided."}
mock_livekit_client.room.update_participant.assert_not_called()
def test_toggle_hand_session_authentication_is_not_accepted(
mock_livekit_client, room, user
):
"""Test toggle hand is not granted by a session, whatever the user's room role."""
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
client.force_authenticate(user=user)
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_session_authentication_is_not_accepted(
mock_livekit_client, room, user
):
"""Test rename is not granted by a session, whatever the user's room role."""
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
client.force_authenticate(user=user)
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "John Doe"}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_livekit_scheme_is_case_insensitive(
mock_livekit_client, room, token
):
"""Test rename claims the LiveKit scheme whatever its casing."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
def test_toggle_hand_livekit_scheme_malformed_header_is_rejected(
mock_livekit_client, room, token
):
"""Test toggle hand rejects a malformed header once the LiveKit scheme is claimed."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "Authorization header must be: X-LiveKit-Token <token>"
}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_livekit_scheme_malformed_header_is_rejected(
mock_livekit_client, room, token
):
"""Test rename rejects a malformed header once the LiveKit scheme is claimed."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "Authorization header must be: X-LiveKit-Token <token>"
}
mock_livekit_client.room.update_participant.assert_not_called()
@@ -7,12 +7,18 @@ from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
from django.utils import timezone as dj_timezone
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...factories import (
RoomFactory,
UserFactory,
UserResourceAccessFactory,
)
from ...models import RoleChoices, RoomAccessLevel
from ..utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -507,3 +513,35 @@ def test_api_rooms_retrieve_administrators(
role=str(user_access.role),
participant_id=None,
)
@pytest.mark.parametrize("access_level", RoomAccessLevel)
@pytest.mark.parametrize("role", [None, *RoleChoices])
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
"""Should not expose when the room was last started, whoever the requester is."""
room = RoomFactory(access_level=access_level, last_started_at=dj_timezone.now())
client = APIClient()
user = UserFactory()
if role is not None:
UserResourceAccessFactory(resource=room, user=user, role=role)
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert "last_started_at" not in response.json()
def test_api_rooms_retrieve_authenticated_with_user_access_token():
"""A user access token should retrieve a room exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["pin_code"] == room.pin_code
assert "accesses" in response.data
@@ -2,11 +2,12 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=redefined-outer-name,unused-argument,no-member
from unittest import mock
import pytest
from livekit import api as livekit_api
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
@@ -470,6 +471,224 @@ def test_start_recording_options_unknown_field_rejected(settings):
assert response.status_code == 400
def test_start_recording_options_encoding_valid(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a valid encoding configuration."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 400
assert not Recording.objects.filter(room=room).exists()
def test_start_recording_persists_resolved_encoding(
settings, mock_worker_service_factory, mock_worker_manager
):
"""The resolved encoding should be persisted in recording.options alongside
the requested resolution/profile for traceability."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options["encoding"] == {
"resolution": "720p",
"profile": "talking_heads",
"resolved": {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
},
}
def test_start_recording_resolution_only_uses_default_profile(
settings, mock_worker_service_factory, mock_worker_manager
):
"""An encoding without a profile should resolve the default profile."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"encoding": {"resolution": "540p"}}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
resolved = recording.options["encoding"]["resolved"]
assert resolved["width"] == 960
assert resolved["height"] == 540
assert resolved["framerate"] == 15
assert resolved["video_bitrate"] == 400
# The requested payload is persisted as sent: no profile was asked for.
assert "profile" not in recording.options["encoding"]
def test_start_recording_forwards_resolved_encoding_to_worker(
settings, mock_worker_service, mock_worker_service_factory
):
"""The resolved encoding should passed on to the worker."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
mock_worker_service.start.return_value = "egress-123"
with mock.patch("core.services.room_management.RoomManagement.update_metadata"):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {
"encoding": {"resolution": "720p", "profile": "talking_heads"}
},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_worker_service.start.assert_called_once_with(
str(room.id),
recording.id,
encoding_options=recording.options["encoding"]["resolved"],
)
def test_start_recording_options_encoding_invalid_resolution(settings):
"""Should reject invalid encoding resolution values."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_encoding_unknown_key_rejected(settings):
"""Should reject unknown keys in encoding configuration."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"bitrate": 9000}},
},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_without_encoding_unchanged(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Requests without encoding should keep existing options behavior."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": "fr"}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": "fr"}
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
@@ -12,7 +12,8 @@ import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from core.factories import RoomFactory, UserFactory
from core.tests.utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -110,7 +111,7 @@ def test_start_subtitle_invalid_token():
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION="Bearer invalid-token",
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
)
assert response.status_code == 403
@@ -130,7 +131,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 404
@@ -150,7 +151,7 @@ def test_start_subtitle_valid_token(
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 200
@@ -180,7 +181,7 @@ def test_start_subtitle_twirp_error(
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 500
@@ -200,7 +201,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 403
@@ -221,10 +222,114 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}
@pytest.fixture
def user_access_token():
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
return generate_user_access_token(UserFactory())
def test_start_subtitle_bearer_scheme_defers_to_next_authentication(
settings, mock_livekit_client, user_access_token
):
"""Test that a "Bearer" header is deferred instead of failing on the LiveKit backend.
The action declares LiveKitTokenAuthentication as its only backend, so a
scheme it does not own must be left to the next one. None follows, so the
request ends up unauthenticated: the body reports missing credentials
rather than an invalid LiveKit token.
"""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
def test_start_subtitle_unknown_scheme_defers(settings, mock_livekit_client):
"""Test that a scheme no backend recognizes is deferred, not rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
def test_start_subtitle_scheme_is_case_insensitive(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test that the LiveKit scheme is claimed whatever its casing."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"x-livekit-token {mock_livekit_token}",
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
def test_start_subtitle_malformed_header_is_rejected(
settings, mock_livekit_client, mock_livekit_token
):
"""Test that a malformed header is rejected once the LiveKit scheme is claimed."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token} extra-part",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authorization header must be: X-LiveKit-Token <token>"
}
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
@@ -3,8 +3,11 @@ Test rooms API endpoints in the Meet core app: update.
"""
import random
from datetime import timedelta
from unittest.mock import patch
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -15,6 +18,7 @@ from ...services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from ..utils import generate_user_access_token
pytestmark = pytest.mark.django_db
@@ -225,6 +229,39 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("method", ["put", "patch"])
def test_api_rooms_update_last_started_at_ignored(method):
"""Should ignore a "last_started_at" value sent by a client.
The field is only ever written by the LiveKit "room_started" webhook: it is not
declared on the serializer and is "editable=False" on the model. A client must
not be able to keep a room alive by postponing its last start date.
"""
user = UserFactory()
last_started_at = timezone.now() - timedelta(days=30)
room = RoomFactory(
name="Old name",
last_started_at=last_started_at,
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
response = getattr(client, method)(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name", "last_started_at": timezone.now().isoformat()},
format="json",
)
assert response.status_code == 200
assert "last_started_at" not in response.json()
room.refresh_from_db()
# The rest of the payload was applied, so the request was not simply rejected
assert room.name == "New name"
assert room.last_started_at == last_started_at
@pytest.mark.parametrize(
"configuration",
[
@@ -410,3 +447,24 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
"configuration": {"can_publish_sources": ["camera"]},
},
)
@pytest.mark.parametrize("privileged_role", ["administrator", "owner"])
def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
"""Role-based permissions apply unchanged with a user access token."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
# A simple member cannot update the room
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 403
# An administrator or an owner can
room.accesses.filter(user=user).update(role=privileged_role)
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "new name"
@@ -5,12 +5,16 @@ Test LiveKitEvents service.
import logging
import uuid
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.models import Room
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
@@ -696,6 +700,82 @@ def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule.assert_not_called()
def test_handle_room_started_records_access(service, settings):
"""Should record the access on a room that is started for the first time."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
other_room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
now = timezone.now()
with mock.patch("django.utils.timezone.now", return_value=now):
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at == now
other_room.refresh_from_db()
assert other_room.last_started_at is None
def test_handle_room_started_overwrites_previous_access(service, settings):
"""Should overwrite the previous access each time the room is started again."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
now = timezone.now()
room = RoomFactory(last_started_at=now - timedelta(days=30))
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
with mock.patch("django.utils.timezone.now", return_value=now):
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at == now
def test_handle_room_started_only_updates_access(service, settings):
"""Should leave the slug and the update date untouched when recording the access."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
Room.objects.filter(pk=room.pk).update(slug="𓆑")
room.refresh_from_db()
updated_at = room.updated_at
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at is not None
assert room.slug == "𓆑"
assert room.updated_at == updated_at
@mock.patch.object(
SIPManagement,
"ensure_dispatch_rule",
side_effect=SIPException("Test error"),
)
def test_handle_room_started_records_access_when_dispatch_rule_creation_fails(
mock_ensure_dispatch_rule, service, settings
):
"""Should still record the access when ensuring the dispatch rule fails."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
with pytest.raises(ActionFailedError):
service._handle_room_started(mock_data)
room.refresh_from_db()
assert room.last_started_at is not None
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
mock_data = mock.MagicMock()
+237 -187
View File
@@ -2,20 +2,20 @@
Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913, C0302
# pylint: disable=W0621,W0613, W0212, R0913, C0302, R0917
# ruff: noqa: PLR0913, PLR0917
import uuid
from unittest import mock
from django.conf import settings
from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.http import HttpResponse
import pytest
from freezegun import freeze_time
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel
from core.services.lobby import (
@@ -151,63 +151,10 @@ def test_get_cache_key(lobby_service, participant_id):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = lobby_service._get_cache_key(room.id, participant_id)
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
expected_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
assert cache_key == expected_key
def test_get_or_create_participant_id_from_cookie(lobby_service):
"""Test extracting participant ID from cookie."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
request = mock.Mock()
request.COOKIES = {}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "generated-id"
mock_uuid4.assert_called_once()
def test_prepare_response_existing_cookie(lobby_service, participant_id):
"""Test response preparation with existing cookie."""
response = HttpResponse()
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
lobby_service.prepare_response(response, participant_id)
# Verify cookie wasn't set again
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie.value == "existing-cookie"
assert cookie.value != participant_id
def test_prepare_response_new_cookie(lobby_service, participant_id):
"""Test response preparation with new cookie."""
response = HttpResponse()
lobby_service.prepare_response(response, participant_id)
# Verify cookie was set
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie is not None
assert cookie.value == participant_id
assert cookie["httponly"] is True
assert cookie["secure"] is True
assert cookie["samesite"] == "Lax"
# It's a session cookies (no max_age specified):
assert not cookie["max-age"]
def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -271,94 +218,99 @@ def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_public_room(
mock_generate_config, lobby_service, participant_id, username
mock_generate_config, lobby_service, participant_id, username, settings
):
"""Test requesting entry to a public room."""
request = mock.Mock()
request.user = AnonymousUser()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.UNKNOWN,
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
cache.set(
f"mocked-cache-prefix_{room.id}_{participant_id}",
{
"id": participant_id,
"username": username,
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
user=user,
username=username,
color=participant.color,
configuration=room.configuration,
participant_id="test-participant-id",
participant_id=participant_id,
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_trusted_room(
mock_generate_config, lobby_service, participant_id, username
mock_generate_config, lobby_service, participant_id, username, settings
):
"""Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock()
request.user = UserFactory()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.UNKNOWN,
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
cache.set(
f"mocked-cache-prefix_{room.id}_{participant_id}",
{
"id": participant_id,
"username": username,
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
user=user,
username=username,
color=participant.color,
configuration=room.configuration,
participant_id="test-participant-id",
participant_id=participant_id,
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.enter")
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
@mock.patch("core.services.lobby.LobbyService._create_participant")
def test_request_entry_new_participant(
mock_enter, lobby_service, participant_id, username
mock_create, mock_notify, lobby_service, participant_id, username
):
"""Test requesting entry for a new participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
"""A new participant gets a server-minted identifier - any provided
one is unknown to the lobby and therefore discarded - and the room is
notified of the entry request."""
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=None)
participant_data = LobbyParticipant(
@@ -368,14 +320,20 @@ def test_request_entry_new_participant(
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
mock_enter.return_value = participant_data
mock_create.return_value = participant_data
participant, livekit_config = lobby_service.request_entry(room, request, username)
forged_id = str(uuid.uuid4())
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=forged_id
)
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
# The provided identifier was looked up, found unknown, and replaced
# by a freshly minted participant
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
mock_create.assert_called_once_with(username)
mock_notify.assert_called_once_with(str(room.id))
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
@@ -383,9 +341,7 @@ def test_request_entry_waiting_participant(
mock_refresh, lobby_service, participant_id, username
):
"""Test requesting entry for a waiting participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -396,10 +352,11 @@ def test_request_entry_waiting_participant(
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
participant, livekit_config = lobby_service.request_entry(room, request, username)
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.WAITING
assert livekit_config is None
@@ -409,82 +366,122 @@ def test_request_entry_waiting_participant(
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_accepted_participant(
mock_generate_config, lobby_service, participant_id, username
mock_generate_config, lobby_service, participant_id, username, settings
):
"""Test requesting entry for an accepted participant."""
request = mock.Mock()
request.user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
cache.set(
f"mocked-cache-prefix_{room.id}_{participant_id}",
{
"id": participant_id,
"username": username,
"status": "accepted",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
user=user,
username=username,
color="#123456",
configuration=room.configuration,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_accepted_participant_username_is_bound(
mock_generate_config, lobby_service, participant_id, settings
):
"""An accepted identifier must join under the username the host accepted.
The participant identifier is a bearer value: a stolen or replayed
identifier must not be able to enter the room under a different
display name than the one the acceptance decision was made on.
"""
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._save_participant(
room.id,
LobbyParticipant(
id=participant_id,
username="accepted-name",
status=LobbyParticipantStatus.ACCEPTED,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(
room, user, "spoofed-name", participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
assert mock_generate_config.call_args.kwargs["username"] == "accepted-name"
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_participant_with_role(
mock_generate_config, lobby_service, participant_id, username
mock_generate_config, lobby_service, participant_id, username, settings
):
"""Test requesting entry for a participant with a role on the room."""
request = mock.Mock()
request.user = UserFactory()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
UserResourceAccessFactory(resource=room, user=user, role="administrator")
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
cache.set(
f"mocked-cache-prefix_{room.id}_{participant_id}",
{
"id": participant_id,
"username": username,
"status": "accepted",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
participant, livekit_config = lobby_service.request_entry(
room, user, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
user=user,
username=username,
color="#123456",
configuration=room.configuration,
participant_id="test-participant-id",
role="administrator",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
@@ -495,87 +492,70 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
"mocked_cache_key", django_settings.LOBBY_WAITING_TIMEOUT
)
lobby_service._index_touch.assert_called_once_with(room.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
@freeze_time("2025-01-01 10:00:00")
def test_enter_success(
def test_create_participant_not_persisted_until_saved(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test successful participant entry."""
"""Creation is in-memory; explicitly saving persists and indexes the participant."""
mock_generate_color.return_value = "#123456"
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
participant = lobby_service._create_participant(username)
mock_generate_color.assert_called_once_with(participant_id)
# The identifier is minted server-side
uuid.UUID(participant.id)
mock_generate_color.assert_called_once_with(participant.id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_not_called()
mock_index_add.assert_not_called()
lobby_service._save_participant(room.id, participant)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
timeout=django_settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
mock_index_add.assert_called_once_with(room.id, participant_id)
mock_index_add.assert_called_once_with(room.id, participant.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_with_notification_error(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
"""A notification error must not break the entry request flow."""
mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
lobby_service._notify_entry_request("room-id")
mock_generate_color.assert_called_once_with(participant_id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
mock_notify.assert_called_once_with(
room_name="room-id", notification_data={"type": "participantWaiting"}
)
mock_index_add.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
@@ -627,7 +607,9 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
def test_list_waiting_participants(
mock_cache, lobby_service, participant_dict, settings
):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
@@ -649,8 +631,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"""Test listing multiple waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = {
"status": "waiting",
@@ -694,7 +676,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service, settings):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
@@ -712,8 +694,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
"""Test listing waiting participants with one valid and one corrupted entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
valid_participant = {
"status": "waiting",
@@ -753,8 +735,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"""Test listing only waiting participants (not accepted/denied)."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = {
"status": "waiting",
@@ -797,7 +779,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
timeout=django_settings.LOBBY_ACCEPTED_TIMEOUT,
)
@@ -811,7 +793,7 @@ def test_handle_participant_entry_deny(mock_update, lobby_service, participant_i
room.id,
participant_id,
status=LobbyParticipantStatus.DENIED,
timeout=settings.LOBBY_DENIED_TIMEOUT,
timeout=django_settings.LOBBY_DENIED_TIMEOUT,
)
@@ -958,12 +940,12 @@ def test_clear_room_empty(settings, lobby_service):
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_participant_cache(lobby_service):
def test_clear_participant_cache(lobby_service, settings):
"""Test clearing a specific participant entry from cache."""
room_id = uuid.uuid4()
participant_id = "test-participant-id"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
participant_data = {
"status": "waiting",
"username": "test-username",
@@ -986,7 +968,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
room_id = uuid.uuid4()
participant_id = "nonexistent-participant"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
assert cache.get(cache_key) is None
lobby_service.clear_participant_cache(room_id, participant_id)
@@ -994,7 +976,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
assert cache.get(cache_key) is None
def test_index_add_members_remove_roundtrip(lobby_service):
def test_index_add_members_remove_roundtrip(lobby_service, settings):
"""The room index records, lists and forgets participant ids."""
room_id = uuid.uuid4()
@@ -1023,9 +1005,10 @@ def test_enter_registers_participant_in_room_index(
"""Entering the lobby must index the participant id for the room."""
room_id = uuid.uuid4()
lobby_service.enter(room_id, participant_id, username)
participant = lobby_service._create_participant(username)
lobby_service._save_participant(room_id, participant)
assert lobby_service._index_members(room_id) == frozenset([participant_id])
assert lobby_service._index_members(room_id) == frozenset([participant.id])
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
@@ -1074,3 +1057,70 @@ def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant
assert redis_client.ttl(index_key) > 10
assert lobby_service._index_members(room_id) == frozenset([participant_id])
@pytest.mark.parametrize(
"cached_status",
[None, LobbyParticipantStatus.WAITING, LobbyParticipantStatus.ACCEPTED],
)
def test_bypass_clears_lobby_admission_before_room_becomes_restricted(
lobby_service, cached_status
):
"""Bypass writes no admission; later restricted entry requires approval."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = AnonymousUser()
participant_id = None
if cached_status is not None:
participant_id = str(uuid.uuid4())
participant = LobbyParticipant(
status=cached_status,
username="Guest",
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._save_participant(room.id, participant)
with (
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test"}
) as generate_config,
mock.patch.object(lobby_service, "_notify_entry_request") as notify,
mock.patch.object(
lobby_service, "_save_participant", wraps=lobby_service._save_participant
) as save,
mock.patch.object(
lobby_service,
"clear_participant_cache",
wraps=lobby_service.clear_participant_cache,
) as clear,
):
admitted, config = lobby_service.request_entry(
room, user, "Guest", participant_id=participant_id
)
assert admitted.status == LobbyParticipantStatus.ACCEPTED
assert config == {"token": "test"}
assert lobby_service._get_participant(room.id, admitted.id) is None
assert admitted.id not in lobby_service._index_members(room.id)
notify.assert_not_called()
save.assert_not_called()
if cached_status is None:
clear.assert_not_called()
else:
clear.assert_called_once_with(room.id, participant_id)
room.access_level = RoomAccessLevel.RESTRICTED
generate_config.reset_mock()
waiting, config = lobby_service.request_entry(
room, user, "Guest", participant_id=admitted.id
)
assert waiting.status == LobbyParticipantStatus.WAITING
assert config is None
generate_config.assert_not_called()
notify.assert_called_once_with(str(room.id))
save.assert_called_once_with(room.id, waiting)
cached = lobby_service._get_participant(room.id, waiting.id)
assert cached is not None
assert cached.status == LobbyParticipantStatus.WAITING
assert waiting.id in lobby_service._index_members(room.id)

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