Compare commits

...

81 Commits

Author SHA1 Message Date
Emmanuel Pelletier 27b0f44b2a 💄(dinum) Apply UI-Kit styles through custom CSS
Use hand-picked tokens from @gouvfr-lasuite/ui-tokens and apply them on
our components.
Depending on the context, we apply light or dark variants.
2026-10-19 13:19:55 +02:00
Emmanuel Pelletier 0b33806d52 💄(css) add [data-page] attr for easier theming through custom CSS
When customizing meet through the custom CSS file
(FRONTEND_CUSTOM_CSS_URL feature), it can be handy to easily know on
what page we are with CSS.
This exposes the current route on the `<html>` tag with a new
`[data-page]` attribute to allow easier targeting through custom CSS.
2026-10-19 13:15:53 +02:00
Emmanuel Pelletier 0fa8e45b3c 💄(css) add classes to components for easier theming through custom CSS
Since Panda generates atomic CSS, it's hard to modify styling through
custom CSS (FRONTEND_CUSTOM_CSS_URL feature).
This adds a few classes on some low-lvl components to allow easy
customization through the custom CSS file.
2026-10-19 13:13:18 +02:00
leo dce67e37f8 ✨(backend) add Menshen token-exchange authentication to external API
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another
service exchanges its user's access token for a token targeting Meet, then
calls the external API on behalf of that user. Tokens are validated by
introspection (RFC 7662). Menshen only reports a token as active when Meet
is among its audiences.

Add Menshen auth to the external API. Only activate it for room creation
scope (other scopes may be added later). If a user doesn't exist for the
provided sub,  create it, unless the provided email is already associated
to a an existing account.

This PR does not implement fallback to email to automatically create
user when no sub or an invalid sub is passed and email is provided, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True.
2026-10-16 08:33:46 +02:00
lebaudantoine ab6e58ecae ✨(all) allow any authenticated user to start a recording
Any authenticated participant can now start a recording on trusted
and public rooms. This is essential, requested by many users, and
should encourage people to authenticate on public rooms. It also
means the starter may hold no role on the room, so who gets access
to the recording has to be decided explicitly.

The part open to discussion is the recording access: who should be
owner/admin on the recording. This commit picks the simplest
design:

* The room's owners and administrators get an `OWNER`
  `RecordingAccess`: they can retrieve, download, stop and delete
  the recording.
* The starter gets a `MEMBER` `RecordingAccess`, unless they already
  own or administer the room, in which case they get `OWNER`. A
  member can retrieve and download the recording but cannot delete
  it: deletion is irreversible and affects everyone with access, so
  it stays with the people responsible for the room.
* Everyone with an access, members included, receives the
  "recording ready" email.
* Room roles are copied when the recording starts: later changes
  to the room's accesses do not affect existing recordings.

The people responsible for a meeting should control its recording
and transcript, and whoever initiated it should be able to access
it on their own. The starter owns the output and is responsible for
sharing it with their colleagues.

Recording members could not retrieve a recording before this
commit. No code path created such accesses until now, but one added
manually through the admin now grants read access.

One constraint is respected: the summary service creates a doc,
and the docs API can only attribute it to a single owner. The
summary therefore goes to the oldest access, and the starter's
access is created before the room owners/admins ones, so the
starter receives it. The lookup does not filter on role, since the
starter may only be a member. The previous lookup took `.first()`
with the default newest-first ordering, which with several owners
would have picked a room admin.

Alternatives considered:

* Granting access to every participant allowed to record, or to
  those present when the recording starts: it requires listing
  participants from LiveKit and creating an access for each, and
  widens access by default, for a need no user has expressed.
* A `creator` attribute on the recording row: it only adds a
  migration without adding value to access control, so it was
  dropped.
* Restricting access to the link owner even when someone else
  starts the recording: some organizations may want this for
  security reasons, but it is YAGNI until a few large clients ask
  for it.

I think we should contribute to Docs' api to allow sharing the
transcription document with several users.
2026-10-15 09:55:57 +02:00
briquet 9bc712009a 🐛(backend) lock rooms while updating them
Before this commit, a room update and a soft deletion racing each other
could leave the room active.

Both `perform_update`s now re-read the room with `SELECT ... FOR UPDATE`
inside a transaction to avoid this race condition.
2026-10-15 00:42:32 +02:00
briquet 44f598a672 ⬆️(backend) upgrade pylint to 4.1.3
pylint was pinned below 4.0 because pylint-django used to require it.
pylint-django 2.8.0 accepts pylint 4.

pylint 3.x kept too-many-lines pragma line numbers across modules: a C0302
disable in one file could make the check report on the wrong line
in another and fail depending on how files were spread across jobs.
pylint 4.1 resets them per module.

cf https://github.com/pylint-dev/pylint/pull/11191
2026-10-15 00:42:32 +02:00
briquet 10437d6ae1 ✨(backend) purge soft-deleted rooms after a retention period
Add a ROOM_DELETED_RETENTION_DAYS setting, unset by default. The
purge_inactive_rooms command now also deletes rooms soft deleted more
than that many days ago, whether they are inactive or not. Inactive
soft-deleted rooms are now purged under ROOM_INACTIVITY_DELETION_DAYS
too. As for inactive rooms, a room holding a saved recording that has
not expired yet is kept.
2026-10-15 00:42:32 +02:00
leo 44bfef69ca ✨(rooms) soft delete rooms from internal and external API
Before this commit:
1. Rooms could be hard-deleted from the internal API (unused by the
   frontend).
2. The external API had no way to delete a room.
3. The admin could hard-delete rooms.

What this commit does:
1. Add a `deleted_at` field to Room. `Room.objects` hides soft-deleted
   rooms; `all_objects` stays the default manager so deleted rooms keep
   reserving their slug and PIN code.
2. Soft delete rooms from the internal API (replacing hard delete) and
   the external API (new DELETE, owner only, `rooms:delete` scope). The
   LiveKit room is closed and its participants disconnected; if closing
   fails, the soft delete is rolled back and an error is returned.
3. Answer 410 Gone on detail operations on a deleted room (after
   permission checks). Its can still be read but not modified.
4. Close LiveKit rooms recreated after deletion by participants still
   holding a valid token (room_started webhook).
5. Frontend: participants in the call land on the feedback page without
   the rejoin button, lobby participants see a "meeting deleted" screen,
   and opening the link shows an error instead of recreating the room.

Untouched by this commit:
- The admin still hard-deletes rooms, without closing the LiveKit room,
  and lists soft-deleted rooms alongside active ones.
- Purging soft-deleted rooms (hard deletion) is not handled.
- Scheduled deletion is not handled.
2026-10-15 00:42:32 +02:00
Roee Sivan eb422309b9 ✨(backend) allow static feature flags without an analytics backend
Feature flags are read from the analytics backend. Without one,
NoOpAnalytics returned no flag, so "summary-enabled" was always off and
a self-hosted instance could not get automatic summaries without PostHog.

NoOpAnalytics now accepts a static "feature_flags" mapping through the
existing ANALYTICS_BACKEND_SETTINGS, and get_analytics() passes the
settings to it. Nothing changes when no flag is declared.

Fixes #1610
2026-10-14 00:19:55 +02:00
leo 4854fc0c13 🔒️(devex) bind ports in compose.yaml to localhost
Some containers were being exposed to local networks. Bind them
to localhost, to tighten security. Don't apply this binding to
LiveKit (exposed on 7880), as we access it using 127.0.0.1.nip.io:7880.
2026-10-13 18:21:04 +02:00
lebaudantoine d930fa1ba6 ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
Case-insensitive user lookups during token issuance currently take
around 100 ms in production, accounting for roughly a third of the
total 300 ms token request trace.

Add an `UPPER(email)` index on the user table so these lookups can
use the index instead of scanning, cutting the dominant cost on
the token issuance path.
2026-10-13 15:20:04 +02:00
lebaudantoine 6f1414b34e 🔖(minor) bump release to 1.35.0 2026-10-13 09:34:46 +02:00
lebaudantoine 0194f7f3f3 ⚡️(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-11 23:37:00 +02:00
Roee Sivan 0bde103911 🔧(project) silence gitlint regex-style deprecation warning
gitlint prints a warning for each ignore-by-title rule because it will switch
its regexes from match (start of the string) to search (anywhere) semantics.

Both ignore-by-title regexes are already anchored with ^, so they match the
same titles either way. Setting regex-style-search opts in to the new behavior
now and removes the warning.

Fixes #96
2026-10-11 23:34:02 +02:00
lebaudantoine c7c41abcb5 ✨(frontend) let users pick the recording encoding
Add a Recording tab to the settings dialog with two selectors, a
content profile and a resolution, building on the per-recording
encoding API.

The goal is to let an instance default to a cheaper profile and
resolution to save egress CPU, while users who really need a smooth
HD recording can still opt in. Most users only need a recording of
acceptable quality, so the default stays cheap and configuring it is
optional.

Profiles are labelled by content ("Talking heads", "Presentations and
text", ...) rather than by fps or bitrate: users know what they are
about to record, not which encoder settings fit it. Each profile shows
a one-line description of its trade-off, so picking one requires no
technical knowledge.

The panel gets a third row, matching the meeting language row of the
transcript panel. It is a shortcut to the settings, without adding
mental load about how to configure a recording. It reads as a short
statement with a trailing "Change" link, like the informational rows
above it, and stays on a single line in every locale: the statement
truncates if a translation gets too long, the link never wraps.

The selected options are intentionally not shown in the panel.
Several users can access it, and each would see their own local
configuration while the running recording may use another user's
encoding, which would be misleading. The issue is milder in the
settings tab, which is frozen while a recording is starting or in
progress.

The selectors are disabled from the moment a start request is sent
until the recording is saved, for both screen recordings and
transcripts. Settings are sent once, when the recording starts, so
changing them afterwards would have no effect and suggest otherwise.

Choices are persisted in localStorage to keep things simple,
consistent with the meeting language, which is already stored
client-side.

The backend now exposes the available resolutions and profiles, and
their defaults, in the frontend configuration, so the UI follows
whatever the operator configures instead of hardcoding values. Stored
choices are checked against it before being sent: a profile removed by
the operator falls back to the instance default instead of being
rejected by the API. No encoding is sent when per-recording encoding
is disabled, as the backend would reject it. Operator-defined values
without a translation are shown by their raw key.

Alternatives considered:

- Storing the preference in room metadata: it would turn a pre-start,
  personal choice into shared editable state, requiring a write
  endpoint, permission checks and handling of concurrent edits.
- Notifying participants of the chosen encoding: notifications are
  one-off, so users who join or reload after the recording started
  would miss them, and the information is noise for most participants.

Next steps:

This is a deliberately small first iteration, meant to deliver most
of the value and evolve with feedback and usage. Possible next steps:
- persist the preferences in the backend instead of localStorage
- store the encoding chosen for a recording on the room (or on a
  session, if such a model is introduced) and read it from there
  rather than from user settings, so every user sees the same value
2026-10-11 23:02:57 +02:00
Roee Sivan 531774fbda 🐛(frontend) keep the admin panel in place when changing a setting
React Aria renders the radio and switch inputs visually hidden and
absolutely positioned. Their labels were not positioned, so in the
side panel the inputs were anchored to the aside and did not scroll
with its content. Focusing one made Chrome scroll the aside, which
has no scrollbar, hiding the panel content.

Position the labels so each hidden input stays inside its control.
2026-10-11 16:08:25 +02:00
Ovgodd 86d97b427c 🐛(frontend) play the speaker test when no output is picked
The speaker test called setSinkId('default'). Firefox rejects it
and playback stopped. Skip that id and play on the system output.
2026-10-11 00:16:47 +02:00
Ovgodd b0bb5bc61b 🐛(frontend) omit the default speaker id sent to LiveKit
Firefox rejects setSinkId('default') when a remote audio track
is attached. Omit that id and only send a real device id.
2026-10-11 00:16:47 +02:00
Ovgodd 3773e43d93 ♿️(frontend) show a visible focus outline on menu items
Keyboard focus was cleared with outline:none.
2026-10-09 18:13:14 +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
273 changed files with 11593 additions and 3157 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
+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
+53 -176
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:
@@ -238,18 +88,16 @@ 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
# Creates the access key and the bucket on startup
- name: Start Garage
@@ -313,6 +161,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install ffmpeg
run: |
@@ -340,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
@@ -350,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:
@@ -360,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
@@ -381,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
+3
View File
@@ -22,6 +22,9 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length]
# line-length=80
+71 -1
View File
@@ -10,6 +10,75 @@ and this project adheres to
### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- ✨(backend) add room soft-deletion to the external API
- ✨(all) allow any authenticated user to start a recording
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
### 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
- ♿️(frontend) show a visible focus outline on menu items #1797
- ⬆️(backend) upgrade pylint to 4.1.3
### 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
- 🐛(frontend) omit the default speaker id sent to LiveKit
- 🐛(frontend) play the speaker test when no output is picked
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
## [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
@@ -21,6 +90,7 @@ and this project adheres to
- ♻️(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
@@ -393,7 +463,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 ----
+7 -3
View File
@@ -169,7 +169,7 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent
run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
@$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector
.PHONY: run-agents
@@ -186,7 +186,6 @@ run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -260,7 +259,8 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
$(MAKE) test-summary ARGS="$${args}" && \
$(MAKE) test-frontend
.PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@@ -278,6 +278,10 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
bin/pytest-summary $${args}
.PHONY: test-summary
test-frontend: ## run the frontend unit tests
cd $(PATH_FRONT) && npm test
.PHONY: test-frontend
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+3 -3
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- LiveKit advanced features including:
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
+188 -1
View File
@@ -16,6 +16,193 @@ the following command inside your docker container:
## [Unreleased]
### Purging deleted rooms
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
- Inactive soft-deleted rooms are purged along with the other inactive rooms: if `ROOM_INACTIVITY_DELETION_DAYS` is already set, nothing else is needed.
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
### Recording permission
We've introduced the ability for any authenticated participant to start a recording or transcription, but this behavior remains configurable.
You can keep recording and transcription restricted to room admins and owners by setting `RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED` to `false`.
## [v1.34.0]
### 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).
@@ -44,7 +231,7 @@ To migrate a local environment:
### Summary service and metadata collector: boto3 replaces the minio client
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
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
+9 -8
View File
@@ -104,15 +104,16 @@ k8s_yaml(secret_yaml_generic(
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('garage-cors', resource_deps=['garage'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('dev-backend-garage-cors', resource_deps=['dev-backend-garage'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('meet-backend', resource_deps=['dev-backend-postgres', 'dev-backend-garage-cors', 'dev-backend-redis', 'dev-backend-keycloak', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summarize', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
# Trigger once on launch
k8s_resource(
'meet-backend-createsuperuser',
+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
+19 -21
View File
@@ -5,15 +5,17 @@ services:
env_file:
- env.d/development/postgresql
ports:
- "15432:5432"
- "127.0.0.1:15432:5432"
redis:
image: redis:5
ports:
- "127.0.0.1:6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
- "127.0.0.1:1081:1080"
garage:
user: ${DOCKER_USER:-1000}
@@ -69,7 +71,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8071:8000"
- "127.0.0.1:8071:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -135,7 +137,7 @@ services:
nginx:
image: nginx:1.25
ports:
- "8083:8083"
- "127.0.0.1:8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -153,10 +155,11 @@ 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:
- "3000:8080"
- "127.0.0.1:3000:8080"
dockerize:
image: jwilder/dockerize
@@ -172,7 +175,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:22-alpine
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -182,7 +185,7 @@ services:
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
- "127.0.0.1:5433:5432"
env_file:
- env.d/development/kc_postgresql
@@ -210,7 +213,7 @@ services:
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
- "127.0.0.1:8080:8080"
depends_on:
- kc_postgresql
@@ -219,10 +222,10 @@ services:
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
- "127.0.0.1:7881:7881"
- "127.0.0.1:7882:7882/udp"
- "127.0.0.1:3478:3478/udp"
- "127.0.0.1:30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -270,11 +273,6 @@ services:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
@@ -285,11 +283,11 @@ services:
env_file:
- env.d/development/summary
ports:
- "8001:8000"
- "127.0.0.1:8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
celery-summary-transcribe:
container_name: celery-summary-transcribe
@@ -303,7 +301,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- garage
develop:
@@ -323,7 +321,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- garage
develop:
+2 -2
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
@@ -45,4 +45,4 @@ services:
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
- "127.0.0.1:8081:8080"
+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
+193 -32
View File
@@ -1,35 +1,3 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
@@ -69,3 +37,196 @@
font-style: normal;
font-display: swap;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
:root {
--fonts-sans: Marianne, "Mona Sans VF", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans Backtick Fix", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--global-font-body: var(--fonts-sans);
--avatar-cap-height: 0.7;
/* use hand-picked variables from UI-kit and apply them to meet's variables */
--c--globals--colors--brand-050: #EDF0FF;
--c--globals--colors--brand-100: #DAE2FF;
--c--globals--colors--brand-250: #A2B6FF;
--c--globals--colors--brand-550: #3E5DE7;
--c--globals--colors--brand-600: #304DDF;
--c--globals--colors--brand-650: #2845C1;
--c--globals--colors--brand-700: #223E9E;
--c--globals--colors--gray-050: #F0F0F3;
--c--globals--colors--gray-100: #DFE2EA;
--c--globals--colors--gray-150: #CFD5DE;
--c--globals--colors--gray-250: #B2B9C7;
--c--globals--colors--gray-300: #A4ABBC;
--c--globals--colors--gray-550: #626A80;
--c--globals--colors--gray-600: #555E74;
--c--globals--colors--gray-650: #4A5267;
--c--globals--colors--gray-700: #3F4759;
--c--globals--colors--gray-750: #363B4C;
--c--globals--colors--gray-800: #2B303D;
--c--globals--colors--gray-850: #222631;
--c--globals--colors--gray-900: #181B24;
--c--globals--colors--white-050: #F6F8F90D;
--c--globals--colors--white-950: #F6F8F9F2;
--c--globals--colors--black-050: #181B240D;
--c--globals--colors--black-850: #181B24D9;
--c--globals--colors--error-550: #CF202D;
--c--globals--colors--error-650: #9D2227;
--colors-primary-800: var(--c--contextuals--background--semantic--brand--primary);
--colors-primary: var(--c--contextuals--background--semantic--brand--primary);
--colors-primary-action: var(--c--contextuals--background--semantic--brand--primary-hover);
--colors-default-subtle-text: var(--c--contextuals--content--semantic--neutral--secondary);
--colors-default-text: var(--c--contextuals--content--semantic--neutral--primary);
--colors-primary-subtle: var(--c--contextuals--background--semantic--brand--secondary);
--colors-primary-subtle-text: var(--c--contextuals--content--semantic--brand--secondary);
--colors-greyscale-250: var(--c--globals--colors--gray-100);
/* for the room's page background color */
--colors-primary-dark-50: var(--c--globals--colors--gray-900);
/* light theme specific UI-kit semantic values */
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-600);
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-850);
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-100);
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-600);
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--black-050);
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--white-950);
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-050);
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-150);
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-550);
/* try to match the UI-kit's control border color, but keep a 3:1 contrast ratio with white to pass WCAG rules on input borders */
--colors-control-border: #8996A9;
}
/* dark theme matching (room page) */
.Box--variant-dark,
.Tooltip,
#control-bar {
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-100);
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-050);
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-700);
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-100);
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-250);
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--white-050);
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--black-850);
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
--c--contextuals--background--semantic--neutral--secondary: var(--c--globals--colors--gray-700);
--c--contextuals--background--semantic--neutral--secondary-hover: var(--c--globals--colors--gray-750);
--c--contextuals--content--semantic--neutral--on-neutral: var(--c--globals--colors--gray-050);
--c--contextuals--background--surface--primary: var(--c--globals--colors--gray-800);
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-750);
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-700);
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-250);
}
.Button--variant-primary {
background-color: var(--c--contextuals--background--semantic--brand--primary);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
.Button--variant-primary:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--brand--primary-hover);
}
.Button--variant-secondary {
border-color: #CFD5DE;
}
.Button--variant-secondaryText {
background: transparent;
color: var(--c--contextuals--content--semantic--brand--tertiary);
}
.Button--variant-secondaryText:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
}
.Button--variant-primaryDark {
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
.Button--variant-primaryDark:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--neutral--secondary-hover);
}
.Button--variant-danger {
background-color: var(--c--contextuals--background--semantic--error--primary);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
.Button--variant-danger:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--error--primary-hover);
}
.Button--variant-primaryTextDark {
background: transparent;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
.Button--variant-primaryTextDark:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
}
.Box--variant-dark.Box--type-popover {
background-color: var(--c--contextuals--background--surface--primary);
}
.Tooltip {
background: var(--c--contextuals--background--semantic--neutral--tertiary);
border-color: var(--c--contextuals--border--semantic--neutral--tertiary);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
/* switch component */
.indicator,
[data-selected="true"] .indicator::before {
background-color: var(--c--contextuals--content--semantic--contextual--primary);
}
.indicator .cross {
color: var(--c--contextuals--content--semantic--contextual--primary);
}
label:not([data-selected="true"]) .indicator::before,
[data-selected="true"] .indicator {
background: var(--colors-primary-800);
}
/* end switch component */
/* prevent impacting the toolbar buttons (primaryDark variant) */
.Button--size-default:is(.Button--variant-primary, .Button--variant-secondary) {
padding-block: 0.4375rem;
}
@@ -4,6 +4,36 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for recordings (authorized by the recordings viewset)
location /media/recordings/ {
auth_request /media-auth-recordings;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
proxy_pass http://garage:9000/meet-media-storage/recordings/;
proxy_set_header Host garage:9000;
proxy_hide_header Content-Disposition;
add_header Content-Disposition "attachment";
}
location = /media-auth-recordings {
internal;
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
+31
View File
@@ -107,6 +107,37 @@ $ npm i
$ npm run dev
```
### LiveKit agents (optional)
The LiveKit agents are not started by `make run`. Each one runs its own
container and stays connected to LiveKit, which costs CPU and memory you
don't need unless you work on the features they power. Start them only
when you need them.
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|---|---|---|---|
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
To start both at once:
```shellscript
$ make run-agents
```
Then set the matching settings to `True` and restart the backend so it
picks them up:
```shellscript
$ make run-backend
```
The multi-user transcriber also needs a speech-to-text provider. Configure
`STT_PROVIDER` and its credentials in
`env.d/development/multi_user_transcriber`.
Keep the settings and the agents in sync: if a setting is `True` while its
agent is stopped, the backend still dispatches jobs to it and the feature
fails silently.
---
## Adding Content
+43 -35
View File
@@ -3,7 +3,7 @@
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
@@ -89,17 +89,18 @@ sequenceDiagram
| Option | Type | Default | Description |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **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 +131,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.
+11 -5
View File
@@ -1,6 +1,6 @@
# Room purge
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days, and the rooms deleted by their owner for longer than a configurable retention period. It is disabled by default.
## How it works
@@ -12,16 +12,21 @@ A room is inactive when:
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
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.
Deleting a room through the API only soft deletes it: the room is hidden, its link tells visitors the meeting was deleted, and it keeps its slug and PIN code. A soft-deleted room is purged:
- when it is inactive, as described above: a deleted room is never considered started again, so its inactivity period keeps running, or
- when it was deleted more than `ROOM_DELETED_RETENTION_DAYS` days ago.
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` or `ROOM_DELETED_RETENTION_DAYS` is set.
```bash
python manage.py purge_inactive_rooms # delete the inactive rooms
python manage.py purge_inactive_rooms # delete the inactive and deleted 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:
A recording can only be reached through its room. A room is kept, past its inactivity or retention period, as long as it holds a saved recording its users may still access:
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
@@ -33,7 +38,8 @@ The room is deleted from the database, along with its accesses, its telephony PI
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
and can be reused when a meeting is created from that same URL.
and can be reused when a meeting is created from that same URL. This also applies to a soft-deleted room: once purged, its link
no longer tells visitors the meeting was deleted, and its PIN code can be given to another room.
* 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.
+21
View File
@@ -113,12 +113,33 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+125 -117
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,120 +311,128 @@ 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 |
| 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 |
| 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_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
| 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 | |
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
| 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 | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| 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 |
+27 -11
View File
@@ -70,18 +70,33 @@ 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
@@ -89,10 +104,11 @@ ROOM_TELEPHONY_ENABLED=True
# ROOMKIT_ENABLED = True
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
# Metadata
METADATA_COLLECTOR_ENABLED=True
# LiveKit agents (opt-in, start them with `make run-agents`)
# Metadata (requires the metadata-collector agent)
METADATA_COLLECTOR_ENABLED=False
# Subtitle
# Subtitle (requires the multi-user-transcriber agent)
ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False
+4
View File
@@ -9,6 +9,10 @@ AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
CELERY_BROKER_URL="redis://redis:6379/2"
CELERY_RESULT_BACKEND="redis://redis:6379/2"
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
+1 -7
View File
@@ -1,5 +1,5 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"extends": ["github>suitenumerique/ci//renovate/default"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
@@ -24,12 +24,6 @@
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
+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/*
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.32.1"
version = "1.35.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.32.1"
version = "1.35.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
-4
View File
@@ -31,10 +31,6 @@ persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
+14 -2
View File
@@ -286,9 +286,16 @@ class RoomAdmin(admin.ModelAdmin):
"get_owner",
"created_at",
"last_started_at",
"deleted_at",
]
list_filter = ["access_level", "created_at", "last_started_at", "deleted_at"]
readonly_fields = [
"id",
"created_at",
"updated_at",
"last_started_at",
"deleted_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."""
@@ -483,6 +490,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 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
return NoOpAnalytics(**options)
backend_class = import_string(dotted_path)
return backend_class(**options)
+24 -1
View File
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
"""
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
+1
View File
@@ -9,6 +9,7 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
ROOM_DELETED = "room_deleted"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+15
View File
@@ -44,6 +44,20 @@ def get_frontend_configuration(request):
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
"encoding": {
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
"available_resolutions": list(
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
),
"available_profiles": list(
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
),
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
},
"authenticated_participants_enabled": (
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
),
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
@@ -73,6 +87,7 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
},
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
+30
View File
@@ -0,0 +1,30 @@
"""Exceptions and guards shared by the API endpoints."""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, status
from core import models
class RoomSoftDeleted(exceptions.APIException):
"""Raised when the requested room has been soft deleted."""
status_code = status.HTTP_410_GONE
default_detail = _("This room has been deleted.")
default_code = "room_deleted"
def ensure_room_not_deleted(resource):
"""Raise a 410 Gone if the resource is a soft-deleted room.
Accepts a room or its parent resource, as referenced by accesses. Call it
after permissions are checked, to avoid revealing room.
"""
if isinstance(resource, models.Room):
room = resource
else:
room = getattr(resource, "room", None)
if room is not None and room.is_deleted:
raise RoomSoftDeleted()
+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
+17
View File
@@ -0,0 +1,17 @@
"""Row locks guarding the API write paths."""
import contextlib
from django.db import transaction
from core import models
from core.api.exceptions import ensure_room_not_deleted
@contextlib.contextmanager
def room_locked_for_update(room):
"""Lock a room's row for the duration of a write."""
with transaction.atomic():
locked_room = models.Room.all_objects.select_for_update().get(pk=room.pk)
ensure_room_not_deleted(locked_room)
yield locked_room
+33 -18
View File
@@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
class ParticipantCapability(permissions.BasePermission):
"""Base permission for in-meeting capabilities opened up to participants.
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
- Room admins/owners always hold the capability.
- When the room access level is TRUSTED or PUBLIC, any
session-authenticated user who is currently connected to the meeting
holds it too.
Access level is always read fresh from the DB; only presence is cached,
so changing the room to RESTRICTED takes effect immediately.
so changing the room access level takes effect immediately.
"""
message = "You are not allowed to manage this room's lobby."
message = "You are not allowed to perform this action."
enabled_setting = None
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
def has_object_permission(self, request, view, obj):
"""Check privileges first, then the participant presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: lobby management is reserved for session-authenticated
# Product choice: these capabilities are reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
@@ -217,16 +217,31 @@ class CanManageLobby(permissions.BasePermission):
if obj.is_administrator_or_owner(user):
return True
if obj.access_level != RoomAccessLevel.TRUSTED:
is_open_room = obj.access_level in (
RoomAccessLevel.TRUSTED,
RoomAccessLevel.PUBLIC,
)
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
if not (is_open_room and is_enabled):
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
except (ParticipantNotFoundException, ParticipantsManagementException):
return False
class CanManageLobby(ParticipantCapability):
"""Grant lobby management (list/accept/deny waiting participants).
Public rooms have no lobby: the endpoints short-circuit there, so opening
them to participants exposes nothing.
"""
class CanManageRecording(ParticipantCapability):
"""Grant starting/stopping a room recording (screen recording or transcript)."""
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
+106 -2
View File
@@ -7,19 +7,26 @@ 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
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.api.exceptions import ensure_room_not_deleted
logger = logging.getLogger(__name__)
@@ -108,6 +115,7 @@ class ResourceAccessSerializerMixin:
_("You must be administrator or owner of a room to add accesses to it.")
)
ensure_room_not_deleted(resource)
return resource
@@ -244,6 +252,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 +315,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 +338,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 +686,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"
+167 -22
View File
@@ -42,7 +42,9 @@ from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.exceptions import ensure_room_not_deleted
from core.api.filters import ListFileFilter
from core.api.locks import room_locked_for_update
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
@@ -55,12 +57,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,
@@ -75,12 +79,13 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.room_roles import (
RoomRoleError,
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,8 +170,100 @@ 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.
class RoomViewSet(
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( # pylint: disable=too-many-public-methods
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
@@ -180,6 +277,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."""
@@ -188,10 +289,9 @@ class RoomViewSet(
filter_kwargs = {"pk": self.kwargs["pk"]}
except ValueError:
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
queryset = self.filter_queryset(self.get_queryset())
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
self.check_object_permissions(self.request, obj)
ensure_room_not_deleted(obj)
return obj
def retrieve(self, request, *args, **kwargs):
@@ -243,6 +343,27 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_destroy(self, instance):
"""Soft delete the room and close its LiveKit room.
The room and its recordings are kept in database for traceability.
"""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_DELETED,
{
"room_id": str(instance.pk),
"access_level": instance.access_level,
},
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.
@@ -285,12 +406,20 @@ class RoomViewSet(
)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
"""Persist the room update under a row lock, then sync metadata to LiveKit.
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
The lock is what keeps a concurrent soft delete from being undone: the
full-row write of `save()` would otherwise carry back the `deleted_at`
read before the deletion committed.
"""
room = serializer.save()
with room_locked_for_update(serializer.instance) as locked_room:
serializer.instance = locked_room
old_configuration = locked_room.configuration
old_access_level = locked_room.access_level
room = serializer.save()
if (
room.configuration == old_configuration
@@ -305,7 +434,7 @@ class RoomViewSet(
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanManageRecording,
],
)
@FeatureFlag.require("recording")
@@ -323,18 +452,22 @@ 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 {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
options=options_data,
)
recording.grant_initial_accesses(starter=self.request.user)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -378,7 +511,7 @@ class RoomViewSet(
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanManageRecording,
],
)
@FeatureFlag.require("recording")
@@ -432,13 +565,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,
@@ -927,6 +1065,13 @@ class ResourceAccessViewSet(
return queryset
def get_object(self):
"""Accesses to a soft-deleted room can be read but no longer modified."""
access = super().get_object()
if self.request.method not in drf_permissions.SAFE_METHODS:
ensure_room_not_deleted(access.resource)
return access
class RecordingViewSet(
mixins.DestroyModelMixin,
+8 -28
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."""
@@ -99,9 +82,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
+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.")
+206 -40
View File
@@ -4,21 +4,113 @@
# ruff: noqa: PLR0913
import logging
from dataclasses import asdict
from functools import lru_cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.core.exceptions import SuspiciousOperation, ValidationError
import requests
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
from menshen_client.exceptions import ResponseParsingError
from rest_framework import authentication, exceptions
from core.models import Application
from core.models import Application, ApplicationScope
from core.services import jwt_token
from core.validators import sub_validator
User = get_user_model()
logger = logging.getLogger(__name__)
def get_token(request, token_type):
"""Extract the token from the Authorization header.
Args:
request: The incoming request
token_type: Expected Authorization scheme (e.g. Bearer), matched
case-insensitively
Returns:
Token string, or None if the request carries no token of this type
Raises:
AuthenticationFailed: If the Authorization header is malformed
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != token_type.lower().encode():
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
return auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
def get_or_create_user_by_sub(sub, email=None):
"""Retrieve the user matching a token subject, creating it if allowed.
Shared by the resource server backends, so that tokens introspected with the
OIDC provider or with Menshen resolve users the same way.
Args:
sub: Subject identifier from the introspected token
email: Email to store on a newly created user
Returns:
User instance, or None if unknown and OIDC_CREATE_USER is unset
Raises:
SuspiciousOperation: If the sub is invalid, the email of a user to create
is already used, or the user account is disabled
"""
if sub is not None:
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation("User info contained an invalid sub claim") from err
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if not settings.OIDC_CREATE_USER:
return None
# Users are never identified by email here. But a second account with the
# same email (e.g. provisionnal account) would break the lookups by email:
# refuse it.
if (
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and email
and User.objects.filter(email__iexact=email).exists()
):
logger.warning("Email already used, not creating user: %s", sub)
raise SuspiciousOperation(
"Email already used by another account."
) from None
user = User(sub=sub, email=email)
user.set_unusable_password()
user.save()
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
@@ -48,6 +140,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
@@ -71,22 +164,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled:
return None
auth_header = authentication.get_authorization_header(request).split()
token = get_token(request, self._token_type)
if not auth_header or auth_header[0].lower() != b"bearer":
if token is None:
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def decode_jwt(self, token):
@@ -159,7 +242,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.
@@ -252,6 +335,113 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
)
# Menshen grants scopes following La Suite's "service:resource:action" convention.
# Map them to the scopes expected by the external API permissions.
MENSHEN_SCOPES_MAPPING = {
"meet:room:create": ApplicationScope.ROOMS_CREATE,
}
@lru_cache(1)
def _get_token_exchange_client():
return TokenExchangeClient(
config=Configuration(
settings.MENSHEN_CLIENT_ID,
settings.MENSHEN_CLIENT_SECRET,
settings.MENSHEN_SERVER_URL,
)
)
class MenshenAuthentication(authentication.BaseAuthentication):
"""Authentication for tokens exchanged through Menshen.
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.
"""
def authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if not settings.MENSHEN_ENABLED:
return None
token = get_token(request, "bearer")
if token is None:
return None
client = _get_token_exchange_client()
try:
response = client.introspect(IntrospectionRequest(token=token))
except (requests.RequestException, ResponseParsingError) as e:
logger.warning("Menshen introspection failed: %s", e)
return None
# Permission classes expect a dict payload in request.auth
payload = asdict(response)
if not payload.get("active"):
# Not a Menshen token, or an expired or revoked one: defer to next
# authentication backend
return None
user = self.get_or_create_user(payload)
scopes = payload.get("scope") or ""
payload["scope"] = [
MENSHEN_SCOPES_MAPPING[scope]
for scope in scopes.split()
if scope in MENSHEN_SCOPES_MAPPING
]
return (user, payload)
def get_or_create_user(self, payload):
"""Retrieve or create the user from the introspection response.
Menshen forwards the `sub` and `email` of the subject token, as introspected
with the OIDC provider.
Args:
payload: Introspection response dict
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
sub = payload.get("sub")
if not sub:
logger.warning("Missing 'sub' in Menshen introspection response")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = get_or_create_user_by_sub(sub, email=payload.get("email"))
except SuspiciousOperation as e:
raise exceptions.AuthenticationFailed(str(e)) from e
if user is None:
logger.warning("User not found: %s", sub)
raise exceptions.AuthenticationFailed("User not found.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
@@ -281,28 +471,4 @@ class ResourceServerBackend(LaSuiteBackend):
logger.debug(message)
raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
return get_or_create_user_by_sub(sub)
+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."""
+103 -10
View File
@@ -4,7 +4,6 @@ import copy
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -22,11 +21,15 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.generics import get_object_or_404
from core import analytics, api, models
from core.api.exceptions import ensure_room_not_deleted
from core.api.feature_flag import FeatureFlag
from core.api.locks import room_locked_for_update
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.transit_code import TransitCodeService
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -74,7 +77,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:
@@ -142,6 +145,7 @@ class ApplicationViewSet(viewsets.ViewSet):
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
@@ -159,13 +163,18 @@ class RoomViewSet(
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
- destroy: Soft delete a room and close its LiveKit room, for owners only
(requires 'rooms:delete' scope)
Detail operations on a soft-deleted room answer 410 Gone.
"""
http_method_names = ["get", "post", "patch", "head", "options"]
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
authentication.MenshenAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -176,6 +185,17 @@ class RoomViewSet(
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def get_object(self):
"""Get the room, answer 410 if it has been deleted.
Permissions are checked first so a deleted room is only revealed to
users who would have been granted access to it.
"""
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
self.check_object_permissions(self.request, room)
ensure_room_not_deleted(room)
return room
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
@@ -239,15 +259,28 @@ class RoomViewSet(
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
def perform_destroy(self, instance):
"""Soft delete the room, close its LiveKit room, then log and track it."""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
"""Persist the room update under a row lock, sync it to LiveKit, then track it."""
previous_values = {
"access_level": serializer.instance.access_level,
"configuration": copy.deepcopy(serializer.instance.configuration),
}
with room_locked_for_update(serializer.instance) as locked_room:
serializer.instance = locked_room
room = serializer.save()
previous_values = {
"access_level": locked_room.access_level,
"configuration": copy.deepcopy(locked_room.configuration),
}
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
@@ -265,3 +298,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,
)
+11 -1
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(
@@ -40,4 +50,4 @@ class SecretField(models.CharField):
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)
return super().pre_save(model_instance, add) # pylint: disable=no-member
+46
View File
@@ -0,0 +1,46 @@
"""Application secrets only: keep fast hashing out of PASSWORD_HASHERS.
Secrets must be securely randomly generated, not human-chosen.
"""
import hashlib
import re
from django.contrib.auth.hashers import check_password
from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes
CLIENT_SECRET_HASH_ALGORITHM = "sha256" # noqa: S105
CLIENT_SECRET_HASH_VERSION = "v0" # noqa: S105
CLIENT_SECRET_HASH_PREFIX = (
f"{CLIENT_SECRET_HASH_ALGORITHM}${CLIENT_SECRET_HASH_VERSION}$"
)
# Accept only the versioned format: sha256$v0$<digest>.
CLIENT_SECRET_HASH_PATTERN = re.compile(
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P<digest>[0-9a-f]{{64}})"
)
def _digest(raw_secret):
"""Return the hex SHA-256 digest of a raw secret."""
return hashlib.sha256(force_bytes(raw_secret)).hexdigest()
def hash_client_secret(raw_secret):
"""Hash a machine-generated application secret without key stretching."""
return f"{CLIENT_SECRET_HASH_PREFIX}{_digest(raw_secret)}"
def verify_client_secret(raw_secret, encoded):
"""Verify the versioned application format or a legacy Django password hash."""
if raw_secret is None:
return False
match = CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)
# Legacy path
if not match:
return check_password(raw_secret, encoded)
return constant_time_compare(match["digest"], _digest(raw_secret))
@@ -1,6 +1,8 @@
"""Purge inactive rooms."""
"""Purge inactive and soft-deleted rooms."""
import operator
from datetime import timedelta
from functools import reduce
from itertools import batched
from logging import getLogger
@@ -22,10 +24,12 @@ class Command(BaseCommand):
- rooms which were last started before that period
- rooms never started and created before that period
Also delete rooms soft deleted more than ROOM_DELETED_RETENTION_DAYS days ago.
Rooms holding a saved recording that has not expired are kept.
"""
help = "Purge inactive rooms"
help = "Purge inactive and soft-deleted rooms"
def add_arguments(self, parser):
parser.add_argument(
@@ -35,53 +39,68 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
"""Browse inactive rooms and delete them chunk by chunk."""
"""Browse purgeable rooms and delete them chunk by chunk."""
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
if not (
settings.ROOM_INACTIVITY_DELETION_DAYS
or settings.ROOM_DELETED_RETENTION_DAYS
):
self.stdout.write(
"Purging inactive rooms is disabled "
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
"Purging rooms is disabled (neither ROOM_INACTIVITY_DELETION_DAYS "
"nor ROOM_DELETED_RETENTION_DAYS is set)."
)
return
now = timezone.now()
inactive_rooms = self.get_inactive_rooms(now)
purgeable_rooms = self.get_purgeable_rooms(now)
inactive_count = inactive_rooms.count()
if not inactive_count:
self.stdout.write("No inactive room to purge.")
purgeable_count = purgeable_rooms.count()
if not purgeable_count:
self.stdout.write("No 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}")
self.stdout.write(f"[dry-run] {purgeable_count} room(s) would be purged:")
rooms = purgeable_rooms.values_list("name", "deleted_at")
for name, deleted_at in rooms.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}{' (deleted)' if deleted_at else ''}")
return
purged_count = 0
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
rooms = purgeable_rooms.values_list("pk", "slug", "deleted_at").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)
for room_id, slug, deleted_at in chunk:
state = "deleted" if deleted_at else "inactive"
logger.info("Purging %s room %s (%s)", state, room_id, slug)
_, deleted_by_model = inactive_rooms.filter(
pk__in=[room_id for room_id, _ in chunk]
_, deleted_by_model = purgeable_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).")
self.stdout.write(f"Purged {purged_count} room(s).")
@staticmethod
def get_inactive_rooms(now):
"""Return the rooms inactive for too long that no recording protects."""
def get_purgeable_rooms(now):
"""Return the inactive or long-deleted rooms 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
)
Soft-deleted rooms are hidden by `Room.objects`, hence `Room.all_objects`.
"""
conditions = []
if settings.ROOM_INACTIVITY_DELETION_DAYS:
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
conditions.append(
Q(last_started_at__lt=threshold)
| Q(last_started_at__isnull=True, created_at__lt=threshold)
)
if settings.ROOM_DELETED_RETENTION_DAYS:
deleted_before = now - timedelta(days=settings.ROOM_DELETED_RETENTION_DAYS)
conditions.append(Q(deleted_at__lt=deleted_before))
if not conditions:
return Room.all_objects.none()
protected_recordings = Recording.objects.filter(
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
@@ -91,4 +110,6 @@ class Command(BaseCommand):
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
)
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
return Room.all_objects.filter(
reduce(operator.or_, conditions), ~Exists(protected_recordings)
)
@@ -0,0 +1,19 @@
"""Add a separate fast hash while preserving legacy credentials for rollback."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_room_last_started_at"),
]
operations = [
migrations.AddField(
model_name="application",
name="client_secret_sha256",
field=models.CharField(
max_length=255, null=True, blank=True
),
),
]
@@ -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),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-10-07 18:45
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0026_alter_application_scopes'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(
Upper("email"),
name="user_email_upper_idx",
),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-09-16 10:00
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0027_user_email_upper_idx'),
]
operations = [
migrations.AddField(
model_name='room',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterModelOptions(
name='room',
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
),
migrations.AlterModelManagers(
name='room',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
]
+165 -5
View File
@@ -14,6 +14,7 @@ from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.hashers import identify_hasher
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
@@ -25,7 +26,7 @@ from django.utils.translation import gettext_lazy as _
from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, utils
from . import fields, hashers, utils
from .recording.enums import FileExtension
from .validators import sub_validator
@@ -245,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name="unique_email_when_sub_is_null",
)
]
indexes = [
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -406,6 +410,33 @@ class ResourceAccess(BaseModel):
return super().delete(*args, **kwargs)
class RoomQuerySet(models.QuerySet):
"""QuerySet exposing the room lifecycle filters."""
def active(self):
"""Rooms that have not been soft deleted."""
return self.filter(deleted_at__isnull=True)
def deleted(self):
"""Rooms that have been soft deleted."""
return self.filter(deleted_at__isnull=False)
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
It is deliberately not the model's default manager: Django relies on
``_default_manager`` for unique validation, which must see deleted rooms as
they still guard their slug and pin code. Other object relations (e.g.
``recording.room``) go through the base manager and still resolve deleted
rooms, which keeps recordings and their notifications working.
"""
def get_queryset(self):
"""Exclude soft-deleted rooms."""
return super().get_queryset().active()
class Room(Resource):
"""Model for one room"""
@@ -429,6 +460,7 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
deleted_at = models.DateTimeField(null=True, blank=True)
pin_code = models.CharField(
max_length=None,
unique=True,
@@ -445,8 +477,13 @@ class Room(Resource):
editable=False,
)
# Managers
objects = RoomManager()
all_objects = models.Manager.from_queryset(RoomQuerySet)()
class Meta:
db_table = "meet_room"
default_manager_name = "all_objects"
ordering = ("name",)
verbose_name = _("Room")
verbose_name_plural = _("Rooms")
@@ -469,6 +506,28 @@ class Room(Resource):
)
super().save(*args, **kwargs)
@property
def is_deleted(self):
"""Whether the room has been soft deleted."""
return self.deleted_at is not None
def soft_delete(self):
"""Soft delete the room.
The room is hidden from the default manager making it impossible to
list, join or update.
"""
deleted_at = timezone.now()
updated = Room.all_objects.filter(pk=self.pk, deleted_at__isnull=True).update(
deleted_at=deleted_at
)
if not updated:
raise RuntimeError("This room is already deleted.")
self.deleted_at = deleted_at
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -504,7 +563,7 @@ class Room(Resource):
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
if not Room.all_objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
@@ -684,11 +743,38 @@ class Recording(BaseModel):
return {
"destroy": is_owner_or_admin and is_final_status,
"partial_update": False,
"retrieve": is_owner_or_admin,
"retrieve": bool(roles),
"stop": is_owner_or_admin and not is_final_status,
"update": False,
}
def grant_initial_accesses(self, starter):
"""Grant the starter and the room's owners/administrators access to the recording.
The starter's access is created first, so it is the oldest one: the
transcript summary, which goes to a single user, is sent to them.
Room roles are copied when the recording starts: later changes to the
room's accesses do not affect existing recordings.
"""
room_privileged_user_ids = set(
self.room.accesses.filter(
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN]
).values_list("user_id", flat=True)
)
starter_is_privileged = starter.id in room_privileged_user_ids
room_privileged_user_ids.discard(starter.id)
RecordingAccess.objects.create(
user=starter,
role=RoleChoices.OWNER if starter_is_privileged else RoleChoices.MEMBER,
recording=self,
)
RecordingAccess.objects.bulk_create(
RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self)
for user_id in room_privileged_user_ids
)
def is_savable(self) -> bool:
"""Determine if the recording can be saved based on its current status."""
@@ -799,6 +885,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):
@@ -824,6 +911,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,
@@ -839,14 +929,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 celery.exceptions import OperationalError as CeleryOperationalError
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.mail import send_email
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -68,18 +68,20 @@ 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 every user with access to the recording
when it is ready: its starter and the room's owners/administrators.
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 = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.filter(recording_id=recording.id)
.order_by("created_at")
)
@@ -88,10 +90,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 +102,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, CeleryOperationalError) as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@@ -222,6 +205,27 @@ class NotificationService:
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
)
@staticmethod
def _get_summary_recipient(recording: models.Recording):
"""Return the single user who receives the transcript summary.
A recording can have multiple accesses (the starter and the room's
owners/administrators), but its summary is generated only once.
Temporarily, until the document can be created and shared with all
recording accesses, the summary is assigned to the oldest access,
which belongs to the starter (see `Recording.grant_initial_accesses`).
Since the starter may have a MEMBER role, the lookup must not filter by role.
"""
oldest_access = (
models.RecordingAccess.objects.select_related("user")
.filter(recording_id=recording.id)
.order_by("created_at")
.first()
)
return oldest_access.user if oldest_access else None
@staticmethod
def _notify_summary_service_v1(recording: models.Recording):
"""Notify summary service about a new recording."""
@@ -233,14 +237,7 @@ class NotificationService:
logger.error("Summary service not configured")
return False
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
recipient = NotificationService._get_summary_recipient(recording)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
@@ -250,7 +247,7 @@ class NotificationService:
else:
metadata_filename = None
if not owner_access:
if not recipient:
logger.error("No owner found for recording %s", recording.id)
return False
@@ -259,16 +256,16 @@ class NotificationService:
)(recording.worker_id)
payload = {
"owner_id": str(owner_access.user.id),
"owner_id": str(recipient.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"email": recipient.email,
"sub": recipient.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"owner_timezone": str(owner_access.user.timezone),
"owner_timezone": str(recipient.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
"context_language": recipient.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
}
@@ -308,14 +305,7 @@ class NotificationService:
logger.error("Summary service not configured")
return False
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
recipient = NotificationService._get_summary_recipient(recording)
metadata_filename: None | str = None
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
@@ -323,7 +313,7 @@ class NotificationService:
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
if not owner_access:
if not recipient:
logger.error("No owner found for recording %s", recording.id)
return False
@@ -350,8 +340,8 @@ class NotificationService:
}
payload = {
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"user_sub": recipient.sub,
"user_email": recipient.email,
"cloud_storage_url": generate_download_s3_url(
recording.key,
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
@@ -360,20 +350,20 @@ class NotificationService:
"language": recording.options.get(
"language", get_language().split("-")[0].lower()
),
"context_language": owner_access.user.language,
"context_language": recipient.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"user_email": recipient.email,
"title": NotificationService._generate_title(
locale=owner_access.user.language
locale=recipient.language
or recording.options.get("language", get_language()),
room=recording.room.name,
recording_datetime=started_at,
owner_timezone=str(owner_access.user.timezone),
owner_timezone=str(recipient.timezone),
),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"form_link": form_link,
"auto_create_summary": is_user_feature_flag_enabled(
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
),
},
"metadata": metadata_payload,
+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
+33 -10
View File
@@ -271,20 +271,24 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room started event") from e
room_updated_count = models.Room.objects.filter(pk=room_id).update(
try:
room = models.Room.all_objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# The block below is intended to fix the issue where long-lived livekit
# tokens allow users who already entered a room to re-create it,
# even if it was closed.
if room.is_deleted:
self._close_deleted_room(room_id)
return
# Update through the queryset to skip the full_clean run by save()
models.Room.all_objects.filter(pk=room.pk).update(
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:
@@ -292,6 +296,25 @@ class LiveKitEventsService:
f"Failed to create sip dispatch rule for room {room_id}"
) from e
@staticmethod
def _close_deleted_room(room_id):
"""Close a LiveKit room recreated after its room was soft deleted.
LiveKit auto-creates a room on join, so a participant still holding a
valid token can bring a deleted room back to life until the token expires.
"""
logger.warning(
"LiveKit room %s started for a deleted room, closing it", room_id
)
try:
RoomManagement.delete_room(str(room_id))
except RoomNotFoundException:
logger.info("LiveKit room %s is already closed", room_id)
except RoomManagementException as e:
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
+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()
@@ -2,10 +2,13 @@
# pylint: disable=no-name-in-module
import contextlib
import json
from logging import getLogger
from typing import Dict, Optional
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
@@ -15,6 +18,9 @@ from livekit.api import (
)
from core import utils
from core.models import Room
from .sip_management import SIPException, SIPManagement
logger = getLogger(__name__)
@@ -115,6 +121,54 @@ class RoomManagement:
finally:
await lkapi.aclose()
@classmethod
def soft_delete(cls, room: Room):
"""Soft delete a room, remove its SIP routing and close its LiveKit room."""
room.soft_delete()
rule_deleted = False
try:
rule_deleted = cls._delete_dispatch_rule(room)
with contextlib.suppress(RoomNotFoundException):
cls.delete_room(str(room.id))
except Exception:
Room.all_objects.filter(pk=room.pk, deleted_at=room.deleted_at).update(
deleted_at=None
)
room.deleted_at = None
if rule_deleted:
cls._restore_dispatch_rule(room)
raise
@staticmethod
def _delete_dispatch_rule(room: Room):
"""Delete a room's SIP dispatch rule, so its PIN no longer routes calls.
This cannot be left to the room_finished webhook: a roomkit join creates
the rule before any LiveKit room exists, so no room may ever finish.
Returns whether a rule was deleted
"""
if not (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED):
return False
try:
return SIPManagement().delete_dispatch_rule(room.id)
except SIPException as e:
raise RoomManagementException("Could not delete dispatch rule") from e
@staticmethod
def _restore_dispatch_rule(room: Room):
"""Recreate a room's SIP dispatch rule after a failed soft delete."""
try:
SIPManagement().ensure_dispatch_rule(room)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Could not restore dispatch rule for room %s", room.id)
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
+15 -4
View File
@@ -124,7 +124,11 @@ class SIPManagement:
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
"""Delete all SIP inbound dispatch rules associated with a specific room.
A rule deleted meanwhile (e.g. by both a room deletion and its
room_finished webhook) is not an error.
"""
rules_ids = await self._list_dispatch_rules_ids(room_id)
@@ -138,9 +142,16 @@ class SIPManagement:
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
try:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(
sip_dispatch_rule_id=rule_id
)
)
except TwirpError as e:
if e.code != TwirpErrorCode.NOT_FOUND:
raise
logger.info("Dispatch rule %s was already deleted", rule_id)
return True
+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
@@ -2,8 +2,10 @@
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.tasks.mail import send_email
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
"send_email",
)
+74
View File
@@ -0,0 +1,74 @@
"""Send mail using celery task."""
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:
OSError: SMTP or network failure, 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))
@@ -0,0 +1,124 @@
"""
Unit tests for NoOpAnalytics and the analytics backend loader.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from core.analytics import get_analytics
from core.analytics.base import NoOpAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_analytics_cache():
"""Clear the cached backend before and after each test."""
get_analytics.cache_clear()
yield
get_analytics.cache_clear()
# ==============================
# NoOpAnalytics
# ==============================
def test_noop_has_no_feature_flags_by_default():
"""Should keep every feature flag disabled when none is declared."""
backend = NoOpAnalytics()
user = UserFactory()
assert not backend.get_user_feature_flags(user)
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_enables_declared_feature_flags():
"""Should enable a feature flag declared as True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
user = UserFactory()
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@pytest.mark.parametrize("value", [False, None, "true"])
def test_noop_only_enables_feature_flags_set_to_true(value):
"""Should keep a feature flag disabled unless its value is exactly True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
user = UserFactory()
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_ignores_unrelated_settings():
"""Should accept settings meant for another backend without failing."""
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
assert not backend.get_user_feature_flags(UserFactory())
# ==============================
# get_analytics
# ==============================
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
"""Should return a no-op backend with no feature flag by default."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert not backend.get_user_feature_flags(UserFactory())
def test_get_analytics_passes_feature_flags_to_noop(settings):
"""Should forward the feature flags when no backend is configured."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
"""Should instantiate the configured backend with its settings."""
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@@ -2,14 +2,14 @@
from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import SuspiciousOperation
import pytest
from lasuite.marketing.tasks import create_or_update_contact
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
@mock.patch.object(create_or_update_contact, "delay")
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
"""Test successful marketing signup."""
email = "test@example.com"
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
mock_create_or_update_contact.assert_called_once_with(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
@@ -9,6 +9,7 @@ from django.core.management import call_command
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
@@ -19,14 +20,25 @@ COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
BEFORE_PERIOD = timedelta(days=366)
WITHIN_PERIOD = timedelta(days=364)
BEFORE_RETENTION = timedelta(days=31)
WITHIN_RETENTION = timedelta(days=29)
@pytest.fixture(name="purge_enabled", autouse=True)
def fixture_purge_enabled(settings):
"""Enable the purge of the rooms inactive for a year."""
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
settings.ROOM_DELETED_RETENTION_DAYS = None
settings.RECORDING_EXPIRATION_DAYS = 30
@pytest.fixture(name="retention_only")
def fixture_retention_only(settings):
"""Only purge the rooms soft deleted more than 30 days ago."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
settings.ROOM_DELETED_RETENTION_DAYS = 30
def create_at(date, factory, **kwargs):
"""Build an object with the factory as if it was created at the given date."""
with mock.patch("django.utils.timezone.now", return_value=date):
@@ -41,12 +53,12 @@ def call_purge(*args):
def room_exists(room):
"""Tell whether the room is still in database."""
return models.Room.objects.filter(pk=room.pk).exists()
"""Tell whether the room is still in database, soft deleted or not."""
return models.Room.all_objects.filter(pk=room.pk).exists()
def test_purge_inactive_rooms_disabled(settings):
"""Should delete nothing when no inactivity period is configured."""
"""Should delete nothing when neither period is configured."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
@@ -68,7 +80,7 @@ def test_purge_inactive_rooms_without_recording_expiration(settings):
status=models.RecordingStatusChoices.SAVED,
)
assert call_purge() == "Purged 1 inactive room(s).\n"
assert call_purge() == "Purged 1 room(s).\n"
assert not room_exists(room)
assert room_exists(room_with_recording)
@@ -97,7 +109,7 @@ def test_purge_inactive_rooms_started_before_period(caplog):
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 inactive room(s).\n"
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
@@ -120,7 +132,7 @@ def test_purge_inactive_rooms_started_within_period():
last_started_at=now - WITHIN_PERIOD,
)
assert call_purge() == "No inactive room to purge.\n"
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@@ -129,7 +141,7 @@ def test_purge_inactive_rooms_never_started_created_within_period():
"""Should keep a room that was never started but created within the period."""
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
assert call_purge() == "No inactive room to purge.\n"
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@@ -221,7 +233,7 @@ def test_purge_inactive_rooms_dry_run():
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"
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room\n"
)
assert all(room_exists(room) for room in rooms)
@@ -235,5 +247,134 @@ def test_purge_inactive_rooms_several_chunks():
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
output = call_purge()
assert output == "Purged 5 inactive room(s).\n"
assert output == "Purged 5 room(s).\n"
assert not any(room_exists(room) for room in rooms)
def test_purge_inactive_rooms_soft_deleted(caplog):
"""Should delete an inactive room even if it was soft deleted recently."""
now = timezone.now()
room = create_at(
now - BEFORE_PERIOD,
factories.RoomFactory,
deleted_at=now - timedelta(days=1),
)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_before_retention(caplog):
"""Should delete a room soft deleted before the retention period."""
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_within_retention():
"""Should keep a room soft deleted within the retention period."""
room = factories.RoomFactory(deleted_at=timezone.now() - WITHIN_RETENTION)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_retention_only_keeps_inactive_rooms():
"""Should keep inactive rooms that are not deleted when only retention is set."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
def test_purge_deleted_rooms_within_retention_but_inactive(settings):
"""Should delete an inactive room even if it was deleted within the retention."""
settings.ROOM_DELETED_RETENTION_DAYS = 30
now = timezone.now()
room = create_at(
now - BEFORE_PERIOD,
factories.RoomFactory,
deleted_at=now - WITHIN_RETENTION,
)
call_purge()
assert not room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_recording_not_expired():
"""Should keep a deleted room holding a saved recording that has not expired."""
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_recording_expired():
"""Should delete a deleted room along with its expired recordings."""
now = timezone.now()
room = create_at(
now - timedelta(days=40),
factories.RoomFactory,
deleted_at=now - BEFORE_RETENTION,
)
recording = create_at(
now - timedelta(days=40),
factories.RecordingFactory,
room=room,
status=models.RecordingStatusChoices.SAVED,
)
call_purge()
assert not room_exists(room)
assert not models.Recording.objects.filter(pk=recording.pk).exists()
def test_purge_rooms_dry_run_flags_deleted_rooms(settings):
"""Should flag the soft-deleted rooms listed on a dry run."""
settings.ROOM_DELETED_RETENTION_DAYS = 30
now = timezone.now()
rooms = [
create_at(now - BEFORE_PERIOD, factories.RoomFactory, name="Alpha room"),
factories.RoomFactory(name="Beta room", deleted_at=now - BEFORE_RETENTION),
]
factories.RoomFactory(name="Gamma room", deleted_at=now - WITHIN_RETENTION)
assert call_purge("--dry-run") == (
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room (deleted)\n"
)
assert all(room_exists(room) for room in rooms)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_frees_slug():
"""Should let a new room take the slug of a purged deleted room."""
factories.RoomFactory(name="my room", deleted_at=timezone.now() - BEFORE_RETENTION)
client = APIClient()
client.force_login(factories.UserFactory())
call_purge()
response = client.post("/api/v1.0/rooms/", {"name": "My Room!"})
assert response.status_code == 201
assert models.Room.objects.get().slug == "my-room"
@@ -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 celery.exceptions import OperationalError as CeleryOperationalError
from core import factories, models
from core.analytics import UserFeatureFlag
@@ -99,7 +100,7 @@ def test_notify_external_services_unknown_mode(caplog):
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
"""Test successful email notification to every user with access to the recording."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
@@ -132,21 +133,19 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
# Members (e.g. a starter with no privileged room role) are notified too
member = factories.UserFactory(email="member@test.com", language="en-us")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
recording=recording, role=models.RoleChoices.MEMBER, user=member
)
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
assert mock_send_mail.call_count == 3
assert mock_send_mail.call_count == 4
call_args_list = mock_send_mail.call_args_list
@@ -210,6 +209,9 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
_, _, _, recipients4 = call_args_list[3][0]
assert recipients4 == ["member@test.com"]
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
@@ -237,7 +239,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.mail.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
@@ -247,6 +249,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=CeleryOperationalError("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(
@@ -479,3 +535,37 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
assert isinstance(title, str)
# ...so the payload serializes exactly the way ``requests`` serializes it.
json.dumps(payload)
def test_summary_recipient_is_oldest_access():
"""With several accesses, the summary goes to the oldest one."""
recording = factories.RecordingFactory()
oldest = factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
for _ in range(2):
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
assert NotificationService._get_summary_recipient(recording) == oldest.user
def test_summary_recipient_is_member_starter():
"""A starter holding a MEMBER access still receives the summary."""
recording = factories.RecordingFactory()
starter = factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
assert NotificationService._get_summary_recipient(recording) == starter.user
def test_summary_recipient_none_without_access():
"""No access: nobody to send the summary to."""
recording = factories.RecordingFactory()
assert NotificationService._get_summary_recipient(recording) is None
@@ -89,9 +89,9 @@ def test_api_recordings_media_auth_no_abilities():
assert response.status_code == 403
def test_api_recordings_media_auth_wrong_abilities():
def test_api_recordings_media_auth_member():
"""
Test that users with insufficient role permissions cannot download recordings
Members of a recording (e.g. its starter) are allowed to download it.
"""
user = UserFactory()
@@ -102,13 +102,14 @@ def test_api_recordings_media_auth_wrong_abilities():
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
@@ -47,7 +47,8 @@ def test_api_recording_retrieve_authenticated():
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
A user who is a member of a recording (e.g. its starter) should be able
to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
@@ -58,10 +59,8 @@ def test_api_recording_retrieve_members():
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 200
assert response.json()["id"] == str(recording.id)
def test_api_recording_retrieve_administrators(settings):
@@ -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
@@ -112,6 +117,29 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.json() == {"slug": ["Room with this Slug already exists."]}
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
"""
A deleted room keeps its slug: creating a room with the same name should
fail validation rather than hit the database constraint.
"""
RoomFactory(name="my room").soft_delete()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "My Room!",
},
)
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
assert Room.all_objects.count() == 1
def test_api_rooms_create_authenticated_user_default_access_level():
"""
The user's default room access level should be applied to the new room
@@ -312,3 +340,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()
@@ -2,11 +2,16 @@
Test rooms API endpoints in the Meet core app: delete.
"""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...analytics import AnalyticsEvent
from ...factories import RoomFactory, UserFactory
from ...models import Room
from ...models import Room, RoomAccessLevel
from ...services.room_management import RoomManagement, RoomNotFoundException
from ...services.sip_management import SIPManagement
pytestmark = pytest.mark.django_db
@@ -83,12 +88,52 @@ def test_api_rooms_delete_administrators():
assert Room.objects.count() == 1
def test_api_rooms_delete_owners():
@mock.patch("core.api.viewsets.analytics.capture")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_owners(mock_delete_room, _, mock_capture):
"""
Authenticated users should be able to delete a room for which they are directly
owner.
owner. The room is soft deleted, its LiveKit room is closed and a ROOM_DELETED
analytics event is emitted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
mock_capture.assert_called_once_with(
user,
AnalyticsEvent.ROOM_DELETED,
{"room_id": str(room.pk), "access_level": RoomAccessLevel.TRUSTED},
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_owners_room_not_live(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""
Deleting a room that is not live in LiveKit should still soft delete it, and
delete its SIP dispatch rule, as no room_finished webhook will.
"""
settings.ROOM_TELEPHONY_ENABLED = True
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
@@ -99,4 +144,47 @@ def test_api_rooms_delete_owners():
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""
Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 403
mock_delete_room.assert_not_called()
@@ -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,253 @@ 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
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_lobby_not_affected_by_recording_setting(mock_check, settings):
"""The recording opt-out must not leak into the lobby capability."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
@@ -2,7 +2,8 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
# pylint: disable=redefined-outer-name,unused-argument,protected-access
# pylint: disable=no-name-in-module,too-many-lines
import random
from unittest import mock
@@ -19,8 +20,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 +93,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 +119,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 +159,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 +306,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 +336,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 +367,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 +387,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 +418,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 +446,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 +464,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 +475,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 +855,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 +1035,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()
@@ -0,0 +1,412 @@
"""Trusted and public rooms: any authenticated participant present in the meeting can record."""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RecordingFactory, RoomFactory, UserFactory
from core.models import (
Recording,
RecordingStatusChoices,
RoleChoices,
RoomAccessLevel,
)
from core.recording.event.notification import NotificationService
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_worker_manager():
"""Mock the worker service factory and mediator."""
with (
mock.patch("core.api.viewsets.get_worker_service"),
mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class,
):
mediator = mock.Mock()
mediator_class.return_value = mediator
yield mediator
@pytest.fixture(autouse=True)
def enable_recording(settings):
"""Recording must be enabled for the endpoints to be reachable."""
settings.RECORDING_ENABLE = True
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_open_room_present_user_can_start_recording(
mock_check, access_level, mock_worker_manager
):
"""Authenticated + present in a trusted/public room -> 201, starter is member."""
user = UserFactory()
room = RoomFactory(access_level=access_level)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub))
recording = Recording.objects.get()
mock_worker_manager.start.assert_called_once_with(recording)
access = recording.accesses.get()
assert access.user == user
assert access.role == RoleChoices.MEMBER
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager):
"""Presence is memoized; clearing it forces a LiveKit re-check."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/stop-recording/"
assert client.post(url).status_code == 200
assert client.post(url).status_code == 200
assert mock_check.call_count == 1
# participant_left webhook
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.post(url).status_code == 403
assert mock_check.call_count == 2
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=False,
)
def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager):
"""Authenticated but not connected to the meeting -> 403."""
room = RoomFactory(access_level=access_level)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to perform this action."}
assert Recording.objects.count() == 0
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager):
"""Presence is not enough on restricted rooms; LiveKit is not even asked."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 403
mock_check.assert_not_called()
assert Recording.objects.count() == 0
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_open_room_present_user_can_stop_recording(
mock_check, access_level, mock_worker_manager
):
"""Any present authenticated user can stop the active recording."""
room = RoomFactory(access_level=access_level)
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 200
mock_worker_manager.stop.assert_called_once_with(recording)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_admin_does_not_need_presence(mock_check, mock_worker_manager):
"""Admins/owners keep recording rights on any room, without a LiveKit check."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room.accesses.create(user=user, role=RoleChoices.ADMIN)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
mock_check.assert_not_called()
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
def test_open_room_anonymous_forbidden(access_level):
"""Anonymous users never record, even on public rooms."""
room = RoomFactory(access_level=access_level)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 401
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_participant_path_disabled_by_setting(
mock_check, access_level, settings, mock_worker_manager
):
"""Self-hosters can opt out: participants then get 403, LiveKit not asked."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
room = RoomFactory(access_level=access_level)
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
user = UserFactory()
client = APIClient()
client.force_login(user)
start = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert start.status_code == 403
assert stop.status_code == 403
mock_check.assert_not_called()
mock_worker_manager.start.assert_not_called()
mock_worker_manager.stop.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_participant_path_disabled_admin_still_records(
mock_check, settings, mock_worker_manager
):
"""The setting only removes the participant path, never admin rights."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
room.accesses.create(user=user, role=RoleChoices.OWNER)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_public_room_lobby_is_open_but_empty(mock_check):
"""Participants pass the lobby permission on public rooms, which have no lobby."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
client.force_login(user)
listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
entry = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert listing.status_code == 200
assert listing.json() == {"participants": []}
assert entry.status_code == 404
assert entry.json() == {"message": "Room has no lobby system."}
@pytest.mark.parametrize("enabled", [True, False])
def test_config_exposes_authenticated_participants_setting(settings, enabled):
"""The frontend reads the setting from /config/ to gate the UI."""
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled
response = APIClient().get("/api/v1.0/config/")
assert response.status_code == 200
assert response.json()["recording"]["authenticated_participants_enabled"] is enabled
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_room_owners_and_admins_get_access_to_participant_recording(
mock_check, mock_worker_manager
):
"""Room owners/admins own the recording, the starter is member; room members get nothing."""
starter = UserFactory()
room_owner = UserFactory()
room_admin = UserFactory()
room_member = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
room.accesses.create(user=room_admin, role=RoleChoices.ADMIN)
room.accesses.create(user=room_member, role=RoleChoices.MEMBER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
recording = Recording.objects.get()
assert dict(recording.accesses.values_list("user_id", "role")) == {
starter.id: RoleChoices.MEMBER,
room_owner.id: RoleChoices.OWNER,
room_admin.id: RoleChoices.OWNER,
}
# Room admins can see and download it; plain members cannot.
client.force_login(room_admin)
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
client.force_login(room_member)
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager):
"""A room admin who starts the recording gets an exclusive access."""
starter = UserFactory()
co_owner = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room.accesses.create(user=starter, role=RoleChoices.ADMIN)
room.accesses.create(user=co_owner, role=RoleChoices.OWNER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
recording = Recording.objects.get()
assert dict(recording.accesses.values_list("user_id", "role")) == {
starter.id: RoleChoices.OWNER,
co_owner.id: RoleChoices.OWNER,
}
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_participant_can_stop_recording_started_by_someone_else(
mock_check, mock_worker_manager
):
"""Stopping is room-wide on trusted rooms, not tied to who started it."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 200
mock_worker_manager.stop.assert_called_once_with(recording)
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager):
"""The starter's access is created first, so the summary goes to them."""
starter = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
for _ in range(2):
room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"}
)
assert response.status_code == 201
recording = Recording.objects.get()
assert recording.accesses.count() == 3
assert NotificationService._get_summary_recipient(recording) == starter
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_participant_starter_can_retrieve_but_not_delete(
mock_check, mock_worker_manager
):
"""A starter without a privileged room role can download but not delete."""
starter = UserFactory()
room_owner = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
client = APIClient()
client.force_login(starter)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
)
assert response.status_code == 201
recording = Recording.objects.get()
recording.status = RecordingStatusChoices.SAVED
recording.save()
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 403
assert Recording.objects.filter(id=recording.id).exists()
client.force_login(room_owner)
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 204
assert not Recording.objects.filter(id=recording.id).exists()
@@ -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,13 +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
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
@@ -188,6 +193,28 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
assert response.json() == {"detail": "No Room matches the given query."}
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
@pytest.mark.parametrize("lookup", ["id", "slug"])
@mock.patch("core.utils.generate_token", return_value="foo")
def test_api_rooms_retrieve_soft_deleted(
mock_token, lookup, allow_unregistered_rooms, settings
):
"""
Retrieving a soft-deleted room should return a 410, and never fall back
to an unregistered room with the same slug.
"""
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
room.soft_delete()
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_token.assert_not_called()
@mock.patch("core.utils.generate_token", return_value="foo")
@override_settings(
LIVEKIT_CONFIGURATION={
@@ -514,7 +541,7 @@ def test_api_rooms_retrieve_administrators(
@pytest.mark.parametrize("role", [None, *RoleChoices])
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
"""Should not expose when the room was last started, whoever the requester is."""
room = RoomFactory(access_level=access_level, last_started_at=timezone.now())
room = RoomFactory(access_level=access_level, last_started_at=dj_timezone.now())
client = APIClient()
user = UserFactory()
if role is not None:
@@ -525,3 +552,18 @@ def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
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,15 +2,16 @@
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
from ...models import Recording
from ...models import Recording, RoomAccessLevel
from ...recording.worker.exceptions import RecordingStartError
pytestmark = pytest.mark.django_db
@@ -41,8 +42,13 @@ def mock_worker_manager(mock_worker_service):
yield mock_mediator
def test_start_recording_anonymous():
@pytest.mark.parametrize(
"recording_enabled, expected_status",
[(True, 401), (False, 404)],
)
def test_start_recording_anonymous(settings, recording_enabled, expected_status):
"""Anonymous users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = recording_enabled
room = RoomFactory()
client = APIClient()
@@ -51,14 +57,24 @@ def test_start_recording_anonymous():
{"mode": "screen_recording"},
)
assert response.status_code == 401
assert response.status_code == expected_status
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator(settings):
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
@pytest.mark.parametrize("authenticated_participants_enabled", [True, False])
def test_start_recording_restricted_room_non_owner_and_non_administrator(
settings, authenticated_participants_enabled
):
"""
On restricted rooms, non-owner and non-administrator users should not be
allowed to start recordings, whether or not authenticated participants
are allowed to record.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = (
authenticated_participants_enabled
)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -470,6 +486,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."""

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