Compare commits

..

295 Commits

Author SHA1 Message Date
fheslouin 07ee88d376 fix: refactor securityContext 2026-03-26 09:58:48 +01:00
fheslouin c3b61a1894 fix: use docker image best practice for node user 2026-03-26 09:58:09 +01:00
fheslouin a05589c8f6 🔒(helm) add pod and container securityContext
This commit aim at adding a securityContext for
pod and container in Deployment and Job,
it include livekit pods as well

It adds 2 values :
- podSecurityContext : for pods
- securityContext : for containers

Please note that `celeryBackend` Deployment does
not have any values defined in `values.meet.yaml` at the moment.
2026-03-25 07:16:17 +01:00
dependabot[bot] c09c440631 ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 18:23:18 +01:00
dependabot[bot] cd7ce77074 ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
Bumps [hono](https://github.com/honojs/hono) from 4.12.2 to 4.12.7.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.2...v4.12.7)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 18:08:38 +01:00
dependabot[bot] 6d3c26419d ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
Bumps [undici](https://github.com/nodejs/undici) from 6.23.0 to 6.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 17:59:51 +01:00
dependabot[bot] 9dbc38984e ⬆️ Bump flatted from 3.3.1 to 3.4.2 in /src/frontend
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.1 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.1...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 17:50:57 +01:00
Michel-Marie MAUDET 1bd5a294e4 🐛(frontend) fix device selection not applying during conference
Await was missing, leading to inconsistent behavior between select component.
Michel-Marie fixed it.
2026-03-23 16:46:52 +01:00
Michel-Marie MAUDET 4d98ed4977 ♻️(backend) make SESSION_ENGINE configurable via environment variable
Make SESSION_ENGINE configurable through environment variable,
following the same values.Value() pattern already used for
SESSION_COOKIE_AGE. This enables OIDC backchannel-logout by
allowing users to set SESSION_ENGINE to db backend.

Closes #1037
2026-03-23 13:39:36 +01:00
Cyril bf32c073c6 💄(frontend) show OS-specific shortcut in participant tile hint
Participant tile hint uses formatShortcutLabel so Mac users see ⌘ not Ctrl.
2026-03-23 09:13:53 +01:00
lebaudantoine 4f2c4bfaf9 ️(summary) improve accessibility of transcription download link
Update the link label to use explicit text "Download your recording"
instead of generic "following this link."

This ensures blind users understand the purpose of the link
and the behavior of opening a new window.
2026-03-20 10:17:48 +01:00
lebaudantoine dacf705329 🍱(frontend) update logo to the latest version
Replace the previous logo with the most up-to-date brand
assets to ensure consistency across the application and emails.
2026-03-20 09:54:12 +01:00
lebaudantoine 8296738347 ️(backend) improve logo access in screen recording email notification
Update the logo alternative text to include the brand name instead
of a generic "logo image" description.

Use a more descriptive value such as "Logo LaSuite Meet"
to better convey the content to screen reader users.

It closes #1092
2026-03-20 09:54:08 +01:00
Florent Chehab 04be495351 💄(custom-background) add upload indicator with preview
When uploading an image, depending on the available network
there might be a bit of wait while the image is being uploaded.
In this commit we add a preview (grayscale + spinner) to have
UI feedback that the upload is in progress.
2026-03-19 17:27:51 +01:00
Florent Chehab 43185605eb 💄(spinner) enforce spinner height
For some reason the ProgressBar adds a bit of height to the spinner
which makes it hard to center.
2026-03-19 17:27:21 +01:00
Florent Chehab cf3fb208e2 🐛(frontend) auto-select new custom background when not logged in
When not logged in and selecting a new custom personal background
the constant id was causing the new custom background not to be auto
selected.
2026-03-19 17:27:21 +01:00
Florent Chehab 4ca230eb12 🐛(frontend) disable personal custom background while deleting
Prevent users selecting a personal custom background while deleting one.
2026-03-19 17:27:20 +01:00
lebaudantoine 4b5e0cb2a3 ️(frontend) improve button descriptions for More tools actions #1184
The "Transcribe" and "Record" buttons had unclear and misleading
descriptions, both using the verb "record," which caused confusion,
especially for screen reader users.

Update descriptions to clearly reflect each action:
- Transcribe: generate a written transcript of the conversation
- Record: save the meeting as a video

This improves accessibility (RGAA 11.9) and reduces the risk of
users triggering the wrong action.

Closes #1173
2026-03-19 14:48:02 +01:00
lebaudantoine 45f374610f ️(frontend) fix more tools heading hierarchy
Side panel title is an H1, but the hierarchy skips directly to H3.
Fix the heading structure. It closes #1178.
2026-03-19 11:56:16 +01:00
lebaudantoine b419a2bfd2 ️(frontend) fix sidepanel accessibility aria-label
The aria-label only announced the presence of a sidepanel without
including its title.

Append the sidepanel title to improve accessibility and context
for screen readers.

Closes #1176.
2026-03-19 11:21:40 +01:00
lebaudantoine ee8d96bee7 🔖(minor) bump release to 1.11.0 2026-03-19 00:31:32 +01:00
lebaudantoine c65ff2d75d 🩹(frontend) disable subtitle settings when feature is unavailable
Hide or disable settings related to the subtitle feature when
the feature flag is not enabled.
2026-03-18 23:25:20 +01:00
Cyril ea1c90d8ca (feat) add default color option for captions
Add "Default" option (white text on black background) for font and background.
2026-03-18 20:44:25 +01:00
Cyril 3e963e3e6d ♻️(refactor) apply caption color customization to subtitles
Use captionFontColor and captionBackgroundColor in Transcription component.
2026-03-18 20:44:25 +01:00
Cyril 82769128a1 (feat) add caption font color and background settings
Select font color and background color in Accessibility > Captions.
2026-03-18 20:44:25 +01:00
Cyril 05f4ce6b2e 🌐(i18n) add captions color settings translations
EN, FR, DE, NL for Accessibility > Captions > Font color / Background color.
2026-03-18 20:44:25 +01:00
Cyril 508984ecfa ️(frontend) add caption font color and background to access store
Persist captionFontColor and captionBackgroundColor in user preferences.
2026-03-18 20:44:25 +01:00
lebaudantoine a05507f73d 👷(ci) run summary test suite
Configure the CI to run the summary tests' suite.
2026-03-18 20:19:52 +01:00
lebaudantoine a27def119c (summary) add tests for the task endpoint
Ensure the API correctly validates and accepts the parameters
required to register a task in the queue.
2026-03-18 20:19:52 +01:00
lebaudantoine fce94f38ce 🧑‍💻(summary) add make command to run summary test suite
Introduce a dedicated make target to execute tests for the
summary component of the stack.
2026-03-18 20:19:52 +01:00
lebaudantoine 83bd9c5ce3 (summary) add minimal test suite for health endpoint
Based on cameldev's work, introduce basic tests to ensure the
health endpoint responds correctly.
2026-03-18 20:19:52 +01:00
Florent Chehab bfbfade99a 🐛(frontend) prevent black background image
Prevent showing a black background when the image is not
accessible anymore.

This happens after the user logs out or logs in.
Or the auth is revoked.
2026-03-18 18:49:59 +01:00
Florent Chehab 16daf7b8d3 (frontend) add custom virtual background feature
Add a custom virtual background feature.

If the backend supports uploading files, backgrounds are stored
in the backend for the user.
Otherwise, only one background image can be selected.
2026-03-18 18:49:59 +01:00
lebaudantoine fcad79d662 📌(agents) unpin OpenSSL and related dependencies
The base image now includes OpenSSL 3.5.5, which resolves
CVE-2025-15467.

Remove explicit pinning of OpenSSL and its dependencies.
2026-03-18 11:34:44 +01:00
lebaudantoine 2424ce17ec 👷(ci) re-enable Trivy scan in CI for image builds
Restore Trivy scanning jobs for built images after the situation
was clarified, ensuring vulnerability checks are active again.
2026-03-18 10:26:23 +01:00
camillem 4715905334 docs: fix typo and markdown 2026-03-18 10:24:09 +01:00
renovate[bot] 7c05aedcfe ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] 2026-03-17 18:33:39 +01:00
lebaudantoine 7347fc7c86 🚨(doc) fix changelog linting
Merge an outside contribution, didn't notice it broke the changelog fix it.
2026-03-17 16:45:04 +01:00
Hadrien Blanc ada7d9a666 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor
The getImageData call was using PROCESSING_WIDTH for both dimensions
instead of PROCESSING_WIDTH and PROCESSING_HEIGHT. This caused the
source image data to be 256x256 instead of the expected 256x144, leading
to buffer overflow when writing to the segmentation mask and potential
visual artifacts in Firefox background effects.
2026-03-17 16:41:40 +01:00
lebaudantoine 0cb3fb8e3c 🔧(ci) explicitly set Docker Hub CI permissions to read-only
Define read-only permissions in the workflow to clarify the
expected access level and follow the principle of least
privilege.
2026-03-15 16:53:07 +01:00
lebaudantoine dcb788b57b 🔒️(backend) avoid information exposure through exception messages
Sanitize error handling to prevent leaking internal details when
invalid or malicious requests are sent to the API.

Return generic error responses to reduce the risk of information
disclosure during probing attempts.
2026-03-13 17:33:55 +01:00
lebaudantoine 73bcb9d598 🐛(backend) fix unescaped dot in regex pattern
The dot before (?P<extension>...) was not escaped and matched any
character instead of a literal period.

Escape it to align with MEDIA_STORAGE_URL_PATTERN, which correctly
uses \. for the file extension separator.
2026-03-13 16:19:36 +01:00
lebaudantoine f3e90c3999 ♻️(backend) clarify storage hook message for intentionally ignored req
Update the response message to explicitly state that certain
notifications are ignored on purpose, avoiding confusion
during debugging or log inspection.
2026-03-13 15:42:30 +01:00
lebaudantoine cb4ed3c9d7 🩹(backend) ignore non-recording uploads in storage webhook handler
With the introduction of background file uploads, a misconfigured
MinIO webhook could trigger the storage hook endpoint for unrelated
files.

While the dev setup now filters events via the MinIO lifecycle
configuration, add a safeguard at the application level.

Enforce a stricter filepath regex when parsing storage hook events
and ignore files outside the recording output directory.

Return a clean 200 response to acknowledge the webhook while
avoiding unnecessary processing.
2026-03-13 15:34:02 +01:00
lebaudantoine f8b0746e73 ♻️(backend) factorize regex used in the S3 event parser
Avoid duplicating patterns that match the same logical unit by
sharing common parts between the two regexes.

This logical factorization improves maintainability and ensures
consistent parsing behavior across S3 event handlers.
2026-03-13 15:34:02 +01:00
lebaudantoine d8ad7a743e ♻️(backend) align recording file regex with MEDIA_URL-based pattern
Initially I avoided coupling the recording path regex with the
setting that provides the Django static URL. However the new
file viewset already relies on it, and Drive does as well.

For consistency, update the recording regex to use the same
STATIC_URL-based approach.

While the coupling may not feel ideal, having two different
regex strategies for similar file paths would be worse.
2026-03-13 15:34:02 +01:00
lebaudantoine 0ba445895c ♻️(backend) merge room name regex and centralize patterns in enums
Combine the regex used in Drive with the one from the recording
feature and centralize them in the enums module.

Although the module name suggests only enums, it also hosts
shared constants used across the codebase.
2026-03-13 15:34:02 +01:00
Florent Chehab 3b719ab9ba (settings) disable file upload by default & max count
Add FILE_UPLOAD_ENABLED setting (default to False, to avoid
a breaking change). Also adds a max_count_by_user sub setting
to restrict the number of uploaded files per user.
2026-03-12 14:55:33 +01:00
Florent Chehab 8887e811d3 🔨(tilt) improve tilt stack stability
* Increase the backoff limit on jobs
* Add redis dependency for livekit
2026-03-12 14:41:58 +01:00
Florent Chehab cb9e994749 ️(helm) reduce initialDelaySeconds and add periods seconds
InitialDelaySeconds was set to 30s which caused the tilt stack
startup to be very slow. In this commit I split the initial delay
and the period arg.
5s seems to be relevant given the nature of the app.
2026-03-12 14:41:57 +01:00
Florent Chehab 1841533d2c 🔨(makefile) add default to meet namespace
Avoids resources being deployed in the wrong namespace
depending on user config.
2026-03-12 14:41:40 +01:00
lebaudantoine 7da99f2116 🔐(backend) avoids revealing the inactive status of an application
Authenticate the application secret before checking whether the
application is inactive.

This avoids revealing the inactive status of an application when
an incorrect secret is provided, preventing an authentication
state oracle and client_id enumeration.
2026-03-12 13:40:11 +01:00
lebaudantoine b0af5e7f35 🧪(backend) add failing test for client_id enumeration issue
Currently the inactive status is revealed before verifying the
secret, creating an authentication state oracle.

Introduce a failing test to capture the issue before
applying the fix.
2026-03-12 13:39:44 +01:00
lebaudantoine dd6bb0ed3e 🔧(backend) trigger webhook only for recording file uploads
With the introduction of file background uploads, only trigger the
webhook for files related to recordings.

Avoid firing the "recording saved" event for other file uploads,
preventing unnecessary queries and false triggers.
2026-03-12 13:33:39 +01:00
lebaudantoine 1306f0bcfe (backend) expose is_active field for Application in Django admin
Add minimal admin support to allow administrators to mark an
application account as inactive if it becomes rogue or compromised.

This capability was missing when the application concept was
initially introduced in the backend.
2026-03-12 13:27:08 +01:00
lebaudantoine ca4494c09e ♻️(backend) align Application model field with is_active convention
Most models in the project use `is_active` rather than `active`.
The Application model was not aligned with this convention.

Rename the field and add a migration to standardize the naming.
Update related tests accordingly.

This change should not introduce breaking changes for external
applications.
2026-03-12 13:26:37 +01:00
lebaudantoine f57db0acc8 ♻️(backend) refactor uploaded file's key format
Remove the user-provided filename while preserving the extension,
and switch to the format `uuid.extension`.

This is more natural than the previous `uuid/.extension` format
and avoids confusion.

Update related tests accordingly.
2026-03-12 10:39:59 +01:00
Florent Chehab 342f992556 🔨(python-env) migrate meet main app to UV
Migrate main meet app to use UV for dependancy management.
Also optimized the backend image build sequence for faster rebuilds
when dependencies don't change.
Also removed compiled django translations files are they are done in the build
process now.

Changes inspired by drive repo.
2026-03-12 10:25:35 +01:00
lebaudantoine d0cf3974ad 🚸(frontend) allow downloading recordings with "failed to stop" status
When support manually marks a recording as "failed to stop," enable
users to download the recording from the frontend.

This ensures users can recover their recordings even if the
automatic stop process fails.
2026-03-11 19:41:45 +01:00
lebaudantoine 0f3eb35c83 🩹(backend) add Django admin action to update recording status manually
Making the status read-only improved security and enforced least
privilege, but in production some recordings could not be properly
stopped, leaving them in an active state.

Introduce an admin action to allow support staff to mark recordings
as "failed to stop" or update their status when necessary.
2026-03-11 19:41:45 +01:00
lebaudantoine d15fb37932 📝(doc) fix changelog formatting
Wrap lines that were excessively long to improve readability.
2026-03-11 19:19:00 +01:00
lebaudantoine 051f33bb1e (backend) add tests for request-entry throttling in the lobby system
Cover throttling behavior for both authenticated users and
anonymous participants identified via the lobby cookie.
2026-03-11 18:07:39 +01:00
lebaudantoine 566eacc8fe (backend) add authenticated user rate throttling on request-entry
Throttle the request-entry endpoint for authenticated users also
to guard against accidental hammering from buggy clients.

Authenticated users are throttled via RequestEntryUserRateThrottle.
Anonymous users are throttled using the lobby participant cookie
through RequestEntryAnonRateThrottle.
2026-03-11 18:07:39 +01:00
lebaudantoine d19023a1ba 🐛(backend) refactor lobby throttling to use participant id instead of IP
use the lobby participant cookie ID as the throttle cache key
rather than the client IP address.

This avoids penalising multiple users behind the same NAT or proxy
and aligns throttling with how LobbyService identifies participants.

This bug was spotted in production where users from ministries behind
NAT was blocked by throttling while using visio.

If no cookie is present yet, skip throttling for the request. The
cookie will be set on the first response and throttling will apply
from subsequent requests.

This throttle is intended to protect against accidental hammering
from buggy clients, not as a security control against DoS attacks.

This is not a security measure, we should use a WAF.
2026-03-11 18:07:39 +01:00
lebaudantoine a6e36f02a7 🌐(frontend) remove references to ProConnect from the login hint
ProConnect (DINUM SSO) should not be mentioned in this context.
Clean up the login hint to avoid confusion.
2026-03-11 17:06:07 +01:00
lebaudantoine c3fd1a89ef 🩹(backend) add page_size to pagination for room endpoints
Align room endpoints with the pagination behavior used across
other API endpoints and resolve issue #1055.
2026-03-11 16:27:02 +01:00
ColorfulRhino 8b1ff536b3 🌐(frontend) improve German translation
Enhancements to the German localization include:

- Refine phrases to sound more natural
- Ensure consistency in words usage
- Adapt phrases to be inclusive and gender-neutral
- Standardize to the "du" form of address, aligning with modern
  practices and other open-source projects like GitLab and the Android
  Open Source Project
2026-03-10 22:56:25 +01:00
Hadrien Blanc 6b08b8da1b 📝 Fix documentation and comment typos 2026-03-10 22:56:08 +01:00
lebaudantoine c4ff42f181 📝(doc) update changelog 2026-03-10 18:36:04 +01:00
lebaudantoine ee0aa0fe5b 🔖(helm) release chart 0.0.17 2026-03-10 18:36:04 +01:00
lebaudantoine 09c871fc29 🩹(helm) fix MinIO ingress port configuration
The wrong port was configured, causing CORS issues
when uploading files directly to the MinIO bucket.

Port 9001 is reserved for the MinIO console;
use the correct API port instead.
2026-03-10 18:36:04 +01:00
Florent Chehab 8496959188 (infra) add celery backend to helm & improvements
* Add a simple celery-backend deployment to helm chart, which
  uses the same docker image as the backend, and runs a single
  worker.
* Update dev keycloack values accordingly
* Update Tiltfile to properly set service dependencies
* Update minio deployment to increase the default max body size limit
  (relevant to uploading files)
2026-03-10 18:36:04 +01:00
Florent Chehab 77105001e0 (settings) configure celery & run task in dedicated queue
Improve celery configuration from env variables and set
meet backend related tasks to run in the `meet-backend` queue
to ease sharing a single redis instance for multiple celery.
2026-03-10 18:36:04 +01:00
lebaudantoine 2d57d38644 🔨(helm) update helm chart for custom background image
Add media_files_svc and ingress_media_files to helm chart
for serving medias related to the new files.

This setup is more straightforward than merging the
existing recording related media ones.

Also updated values to enable file upload by default in dev.

Mostly done by @lebaudantoine.
2026-03-10 18:36:04 +01:00
dependabot[bot] c9d13619a6 ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
Bumps [rollup](https://github.com/rollup/rollup) from 4.44.2 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 16:52:00 +01:00
Florent Chehab b99ec9bb50 🔨(tilt) use default user in dev
This change avoid permission issues when syncing files
with tilt.
2026-03-10 15:51:00 +01:00
Cyril b3f26469c8 🐛(frontend) fix hand icon and queue position alignment
Use inline-flex with alignItems center so number and icon align vertically.
2026-03-10 12:12:42 +01:00
Cyril d5c53c7dd4 ️(frontend) improve ui and qria labels for help article links
Screen readers now announce full link text instead of "Learn more".
2026-03-10 11:36:10 +01:00
Cyril f43ac2e4eb ♻️(refactor) apply caption text size to subtitles
Use accessibilityStore.captionTextSize in Transcription component.
2026-03-10 11:15:52 +01:00
Cyril ea5dd5bc0e (feat) add caption text size setting in Accessibility tab
Extract CaptionsSettings component, Settings > Accessibility > Captions.
2026-03-10 11:14:40 +01:00
Cyril 86427fa2b7 🌐(i18n) add captions text size setting translations
EN, FR, DE, NL for Accessibility > Captions > Text size.
2026-03-10 11:14:39 +01:00
Cyril 3959c3657c ️(frontend) add caption text size to accessibility store
Persist captionTextSize (small/medium/large) in user preferences.
2026-03-10 11:14:08 +01:00
Cyril 191f8abbcc ️(frontend) improve chat a11y for screen readers
Chat messages announced via role="log" when open, toast+announce when closed.
2026-03-09 16:06:08 +01:00
Cyril d91f8bb6e1 Merge branch 'fix/homepage-knowmore-link' 2026-03-09 14:41:26 +01:00
lebaudantoine d612f9b26b 🩹(changelog) fix changelog organisation for v1.10 2026-03-09 14:32:30 +01:00
Cyril 042be17cfa ️(frontend) improve MoreLink a11y and UX on home page
Render MoreLink once in LeftColumn, make full phrase clickable and add icon.
2026-03-09 14:27:39 +01:00
Cyril d00f4fa695 ️(frontend) sync html lang attribute with i18n for screen readers
Set document.documentElement.lang in i18n init and on languageChanged,
2026-03-09 13:32:10 +01:00
Ömer Fatih İlhan fd36469fc2 kubernetes documentation helm path updated 2026-03-06 16:16:14 +01:00
Florent Chehab dc278a6064 (backend) add file upload feature & tests
For the coming features we will need to store files on the meet side.
(for instance user backgrounds).

This commits adds a new Model to manage files, and the associated
serializers & viewsets. All are tested.

This work was heavily inspired by the work done by our friends at
https://github.com/suitenumerique/drive
It build on the same architecture design (upload directly to S3 but
download goes through our proxy), but model is much much simplier
(no folders, no file sharing, etc.).
2026-03-06 11:31:39 +01:00
Florent Chehab 047da94494 (backend) add mimetype detection logic from drive
Reused the logic developed by the team working on drive.
This is usefull for our own upload file backend (that will
come in later commits).

Dockefile was updated to add a required system dependency.

We might want to put this shared logic in a lib.
2026-03-06 11:31:39 +01:00
Florent Chehab 124a8bf8d9 🔨(docker) improve docker ignore
Tweak syntax to ignore folders recursively (important).
And add few missing ones.
2026-03-06 11:22:39 +01:00
Cyril c72c5cae1a ️(frontend) dynamic tab title when connected to meeting
Display room name, date and app title in browser tab
2026-03-05 17:58:00 +01:00
lebaudantoine b564044e70 🔖(minor) bump release to 1.10.0 2026-03-05 14:19:25 +01:00
renovate[bot] 4717143251 ⬆️(dependencies) update django to v5.2.12 [SECURITY] 2026-03-05 12:19:47 +01:00
dependabot[bot] 805e983749 Bump @hono/node-server from 1.19.9 to 1.19.10 in /src/frontend
Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.9 to 1.19.10.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.9...v1.19.10)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-05 12:13:29 +01:00
Cyril 6dfafb7f67 💄(fix) truncate long names with ellipsis in reaction overlay
Long participant names under emoji reactions overflow without truncation.
2026-03-05 11:38:47 +01:00
lebaudantoine e56c0f997e 🩹(frontend) fix overflow in participant metadata layout
Recent styling changes introduced an overflow, causing the network
indicator to be pushed outside of the participant tile.

Remove width: 100% and add a minimal gap to prevent metadata
elements from being too close to each other.
2026-03-04 20:39:09 +01:00
lebaudantoine 61afd94e3a 🩹(frontend) enhance shortcut hint styling using PandaCSS utilities
Refactor styles to leverage PandaCSS inline capabilities for
better clarity and consistency.

Remove an unnecessary div wrapper that was causing a layout shift.
2026-03-04 20:39:09 +01:00
Cyril 3d7aec2b4a ️(frontend) announce selected state to SR in select and menu list
Add visually hidden "selected" text for screen readers.
2026-03-04 19:07:34 +01:00
dependabot[bot] 9c009839f0 Bump minimatch from 3.1.2 to 3.1.5 in /src/frontend
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 17:22:29 +01:00
lebaudantoine ec63ddcd47 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit
Create a separate Ingress resource to isolate traffic targeting the
webhook-livekit endpoint and allow applying specific NGINX
annotations to this route.

Use an exact path match to take precedence over the default /api
regex rule defined in the base Ingress.

No similar change is made for the S3 webhook endpoint, as this
dependency will be removed from the project.
2026-03-04 16:30:01 +01:00
lebaudantoine fcde8757e6 🩹(frontend) remove incorrect reference to ProConnect on the prejoin
Remove incorrect reference to ProConnect (DINUM SSO) from content
literals, where it should not be mentioned by default in the
white labeled version.

It closes #1075
2026-03-04 14:04:37 +01:00
Cyril 9610e606eb ️(frontend) prevent focus ring clipping
Change overflow from hidden to visible on invite dialog
2026-03-04 13:39:02 +01:00
Cyril 8362ac0e24 ️(frontend) shortcuts table: semantic structure and kbd badge
caption, th scope, <kbd> for keys, no Tab stops on rows
2026-03-04 12:09:38 +01:00
Cyril f1ddd7fa2f ♻️(frontend) show shortcut hint only on first grid tile via CSS
Use :first-child and :focus-within to restrict hint visibility to the first tile
2026-03-04 12:08:22 +01:00
Cyril 487340efca ♻️(frontend) move fullscreen and recording shortcuts to their components
Register Ctrl+Shift+F in DesktopControlBar, Ctrl+Shift+L in ToolsToggle
2026-03-04 12:07:39 +01:00
Cyril 7ebc928dd3 (frontend) add Ctrl+Shift+/ to open shortcuts settings
Update toolbar hint and register shortcut to open settings on shortcuts tab
2026-03-04 12:07:39 +01:00
Cyril 85de214ca7 💄(frontend) truncate pinned participant name with ellipsis on overflow
Long participant names are now truncated with an ellipsis.
2026-03-04 11:21:10 +01:00
lebaudantoine e3e34dbf31 ️(frontend) optimize countdown check in IsIdleDisconnectModal.tsx
Using Array.includes runs in O(n) on every second of the countdown.

Replace the array with a Set to achieve O(1) lookups for better
performance.
2026-03-04 10:22:29 +01:00
lebaudantoine 555afe4abd ️(frontend) fix roomId RegExp recompilation
The regex was being recreated on every function call, causing
unnecessary performance overhead.

Hoist the RegExp to a module-level constant to reuse the compiled
pattern.
2026-03-04 10:22:29 +01:00
lebaudantoine 78ddb121e3 ️(frontend) avoid recreating inline array props in VideoTab.tsx
The items array was defined inline, creating a new reference on
every render.

Hoist the array to a module-level constant or memoize it with
useMemo to prevent unnecessary re-renders.
2026-03-04 10:22:29 +01:00
lebaudantoine ca9c7fc152 ️(frontend) avoid non-primitive default props recreation on each render
The empty object literal created a new reference every render,
potentially triggering unnecessary re-renders.

Hoist an EMPTY_PROPS constant to the module level and reuse it
instead of allocating a new object.
2026-03-04 10:22:29 +01:00
lebaudantoine 6e3845d0c1 ️(frontend) fix missing import type in Rating.tsx
Replace runtime import of PostHog with a type-only import to
avoid loading the module at runtime.
2026-03-04 10:22:29 +01:00
lebaudantoine 41b171da68 🩹(frontend) fix double await in Join.tsx
Remove redundant await in videoTrack.setDeviceId call
to avoid unnecessary promise chaining.
2026-03-04 10:22:29 +01:00
lebaudantoine 4ad897e756 ️(frontend) optimize enterRoom calls in useWaitingParticipants
Replace sequential await inside the loop with Promise.all, since
each enterRoom call is independent.

This prevents unnecessary delays when multiple participants are
waiting (e.g. 10 participants previously resulted in ~10x longer
execution time).
2026-03-04 10:22:29 +01:00
lebaudantoine 42647d6d25 🦺(backend) strengthen API validation for recording options
Improve validation of parameters accepted when starting a
recording to prevent unsupported or unexpected values.

Language validation will be further tightened to only accept
languages supported by the transcribe microservice.

Add extensive API validation tests to cover these scenarios.
2026-03-03 19:05:15 +01:00
leo 14526808ab ♻️(summary) clean up code and unify logging in preparation for testing
Refactor the summary service to better separate concerns, making components
easier to isolate and test. Unify logging logic to ensure consistent
behavior and reduce duplication across the service layer. These changes
set up the codebase for granular testing.
2026-03-03 15:44:53 +01:00
Florent Chehab 25167495cc 🐛(migrations) use settings in migrations
Use settings directly in migrations to avoid noop
migrations. This might have undisered side effects
if we change the config over time 'invalid' data will be
in the database.

It's a simple quick fix.
Keeping some migrations that are no useless to avoid changing
too much the migration history for users.

Similar to https://github.com/suitenumerique/people/commit/
469014ac415b25be0ceed08b31a87d2d40d743cd
2026-03-03 14:48:06 +01:00
lebaudantoine 720eb6a93e ♻️(backend) extract forbidden permission fields from the serializer
These fields previously triggered a suspicious operation exception
when passed to the API.

Make the list configurable so the serializer behavior can be
adjusted without requiring a new release.
2026-03-03 13:30:10 +01:00
lebaudantoine bfbf253033 🔒️(backend) enhance API input validation to strengthen security
During the bug bounty, attempts were made to pass unexpected hidden
fields to manipulate room behavior and join as a ghost.

Treat these parameters as suspicious. They are not sent by the
frontend, so their presence likely indicates tampering.

Explicitly allow the parameters but emit warning logs to help detect
and investigate suspicious activity.
2026-03-03 13:30:10 +01:00
lebaudantoine 692e0e359e (backend) install pydantic and django-pydantic-field to strengthen API
Super useful for validation when handling unstructured dictionaries.

Follow qbey's recommendation and align with the
suitenumerique/conversation project approach to improve schema
validation and data integrity.
2026-03-03 13:30:10 +01:00
Cyril 1d23cb889a ️(frontend) announce mic/camera state for screen readers on shortcut
announce "Microphone/Camera turned on/off" when toggling via
keyboard shortcut so screen reader users get feedback
2026-03-03 09:46:47 +01:00
lebaudantoine b2ad423886 🔖(minor) bump release to 1.9.0 2026-03-02 14:33:25 +01:00
lebaudantoine 2c7b4bea04 🔒️(ci) disable Trivy scan pending clarification from Aqua Security
The Trivy GitHub repository was wiped over the weekend, raising
suspicions of a potential supply chain attack.

Temporarily disable the scan until the situation is clarified.
2026-03-02 11:29:31 +01:00
lebaudantoine 1eda18ea6e 🔧(ci) introduce Claude security review GitHub Action
Add automated security review on new pull requests to strengthen
early detection of potential vulnerabilities.

Leverage Claude to help identify security issues and highlight
areas requiring special attention.
2026-03-02 11:29:31 +01:00
Cyril 8d5488c333 ️(frontend) add skip link component for keyboard navigation
Improve a11y: skip to main heading, bypass header. RGAA 12.7.
2026-02-27 22:49:03 +01:00
lebaudantoine 5c0e6b6479 ⬆️(frontend) update react-aria-components to a newer version
The previously pinned version (July release) did not support
passing the aria-disabled prop to React Aria Button.

A more recent release (August) introduced this capability.
Upgrade is required to make Cyril's proposal work.
2026-02-27 19:39:55 +01:00
Cyril 077cf59082 ️(frontend) keep carousel nav buttons focusable at first and last slide
use aria-disabled  to prevent focus loss when reaching slide limits
2026-02-27 19:39:55 +01:00
Cyril 4881fa20f5 ️(frontend) fix carousel focus ring visibility with NVDA
add :focus fallback for nav buttons when focus-visible detection fails
2026-02-27 19:39:55 +01:00
Cyril 116db1e697 ️(frontend) improve IntroSlider accessibility for screen readers
add aria-labels with slide position, carousel semantics, live region
2026-02-27 19:39:55 +01:00
Florent Chehab 4b76e9571f ⬆️ (python) bump minimal required python version to 3.13
We are going to use features only available in python 3.13.
We already ship docker images based on python 3.13.

For https://github.com/suitenumerique/meet/pull/1030
2026-02-27 12:37:14 +01:00
Cyril e8739d7e70 ️(frontend) improve JoinMeetingDialog screen reader
Focus input on modal open and improve screen reader announcements
2026-02-26 18:35:15 +01:00
Florent Chehab 602bcf3185 🩹(devex) fix Makefile special character support
Under some shells echo doesn't work as expected with the special formatting.

Using printf when creating the variables make it work and should be more robust.
2026-02-25 18:08:57 +01:00
leo f5e0ddf692 (summary) add localization support for transcription context text
Transcription and summarization results were always generated
using a French text structure (e.g. "Réunion du..."), regardless
of user preference or meeting language. Introduced basic localization
support to adapt generated string languages.
2026-02-25 18:07:19 +01:00
lebaudantoine cd0cec78ba 🩹(frontend) fix German language preference update
German was missing from the frontend/backend language list in the
sync hook, causing user preference updates to be ignored.

Add the language to ensure preference changes are properly applied.
2026-02-25 17:01:02 +01:00
leo e647787170 ♻️(devex) run service as part of make bootstrap
Add run to make bootstrap, thus starting the service. This fixes a
mismatch with development documentation.
2026-02-25 11:15:34 +01:00
lebaudantoine d76b4c9b9f 🔧(dependencies) update default renovate config
Update default Renovate configuration to open PRs on
the first day of each month instead of weekly.

Security updates remain handled immediately by Dependabot, while
Renovate manages regular dependency updates to keep the project
up to date with third-party packages.
2026-02-24 18:51:49 +01:00
lebaudantoine 09c7edecb8 📌(dependencies) pin Django to a version below 6.0.0
Delay upgrading until the ecosystem around Django 6 matures.
Also prevent Renovate from suggesting updates beyond v6.
2026-02-24 18:51:49 +01:00
lebaudantoine f625df6508 ♻️(backend) refactor external API tests
Refactor tests to avoid duplicating JWT secret key configuration.

Introduce configuration of the JWT audience, which previously had no
default value.
2026-02-24 16:07:23 +01:00
lebaudantoine ac87980a27 ♻️(backend) refactor external API authentication classes
Refactor external API authentication classes to inherit from a
common base authentication backend.

Prepare the introduction of a new authentication class responsible
for verifying tokens provided to calendar integrations.

Move token decoding responsibility to the new token service so it
can both generate and validate tokens.

Encapsulate external exceptions and expose a clear interface by
defining custom Python exceptions raised during token validation.

Taken from #897.
2026-02-24 16:07:23 +01:00
lebaudantoine 7cab46dc29 ♻️(backend) encapsulate token generation in a service
Encapsulate token generation logic for authenticating to the
external API in a well-scoped service.

This service can later be reused in other parts of the codebase,
especially for providing tokens required by calendar integrations.

Commit was cherry picked from #897
2026-02-24 16:07:23 +01:00
Cyril 259b739160 ️(a11y) fix focus ring on tab container components
Suppress inherited global focus ring on Tabs, TabList, and TabPanel containers.
2026-02-24 14:37:49 +01:00
lebaudantoine 6f77559633 ⬆️(backend) update python dependencies
Updating ruff led me to refactor an unnecessary lambda
2026-02-24 12:23:22 +01:00
Cyril 2cdf19de77 ♻️(frontend) remove redundant formatLongPressLabel helper
Use i18next interpolation directly in useShortcutFormatting
2026-02-24 09:16:16 +01:00
Cyril fcf08a6dbd 🌐(frontend) localize SR modifier labels
Replace hardcoded 'Alt'/'Shift' in SR formatter with i18next
labels. Use Option/Alt distinction on Mac like Ctrl/Command.
2026-02-24 09:13:03 +01:00
Cyril 7bf623f654 🌐(frontend) localize SR modifier labels
Replace hardcoded 'Alt' and 'Shift' in the SR shortcut
2026-02-24 09:06:53 +01:00
lebaudantoine 1c1d1938d9 🚚(frontend) rename "wellknown" directory to "well-known"
Fix a typo introduced while configuring the correct directory for
automatic container view opening on Windows.
2026-02-23 20:20:54 +01:00
lebaudantoine ddb81765f3 🔧(ci) explicitly set CI permissions to read-only as a precaution
Clarify intent and avoid any ambiguity regarding granted permissions.
2026-02-23 18:00:04 +01:00
Ovgodd 8ca52737cd (frontend) introduce a shortcut settings tab
Work adapted from PR #859 and partially extracted to ship as a
smaller, focused PR.

This allows users to view the full list of available shortcuts.
An editor to customize these shortcuts may be introduced later.
2026-02-23 14:26:52 +01:00
Stephan Meijer 87b9ca2314 👷(docker) add arm64 platform support for image builds
Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-23 14:06:54 +01:00
lebaudantoine 8a6419da44 🔨(livekit) pin LiveKit version in the dev stack to match production
Avoid potential synchronization issues caused by version drift.
2026-02-23 12:14:00 +01:00
lebaudantoine 127d4e1d5a ⬆️(frontend) update livekit-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine 99cbc1f784 ⬆️(frontend) update panda-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine 246312c51c ⬆️(frontend) update i18next-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine 1b09683938 ⬆️(frontend) update vite-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine db3d3d61ef ⬆️(frontend) update tanstack-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
Cyril c1a797c2c1 💄(frontend) add focus ring to reaction emoji buttons
show outline on keyboard focus, fix when sr is opened
2026-02-23 10:29:17 +01:00
lebaudantoine 4d6a7573c4 ⬆️(mail) update mail-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 10:28:45 +01:00
dependabot[bot] 0b73fd8f06 Bump undici from 6.19.8 to 6.23.0 in /src/frontend
Bumps [undici](https://github.com/nodejs/undici) from 6.19.8 to 6.23.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.19.8...v6.23.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.23.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 10:00:40 +01:00
lebaudantoine e362765b21 🔒️(frontend) uninstall curl from the frontend production image
Remove unnecessary package to reduce image size and surface area.
2026-02-20 18:27:17 +01:00
lebaudantoine be79fdac80 🩹(summary) fix pip uninstall order in build stages
Pip was removed before copying the builder stage output, which caused
it to be reinstalled unintentionally. Adjust the order to align with
the backend image behavior.
2026-02-20 18:27:17 +01:00
François Petitit 75a15a0004 Fix typo in buildpack environment variable name 2026-02-20 18:26:24 +01:00
Cyril 3087dfe486 ♻️(frontend) replace custom reactions toolbar with react aria popover
use react aria primitives for escape, focus containment and restore
2026-02-20 18:21:33 +01:00
lebaudantoine 9916ab7d7e 🔖(minor) bump release to 1.8.0 2026-02-20 13:44:19 +01:00
lebaudantoine bd2ad3bb99 📝(changelog) update changelog with recent changes
Update changelog.
2026-02-20 13:17:45 +01:00
lebaudantoine f02fbc85a3 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
Upgrade OpenSSL and related dependencies to address CVE-2025-15467
in meet-agents.

This vulnerability was blocking the image signature workflow, as it
is classified as a critical dependency.
2026-02-20 13:17:45 +01:00
lebaudantoine 4fd4e074e0 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
Protobuf is a transitive dependency. Pin it to version 6.33.5 to
address CVE-2026-0994.
2026-02-20 13:17:45 +01:00
lebaudantoine ec3d4f7462 🔒️(agents) uninstall pip from the agents image
Reduce surface area and keep the runtime image minimal.
2026-02-20 13:17:45 +01:00
lebaudantoine 4507325331 🔒️(summary) switch to Alpine base image
Reduce surface area and keep the runtime image minimal.

Alpine 3.22 provides ffmpeg v6 as the latest version.
Alpine 3.23 does not include ffmpeg v7, so upgrade directly to v8.

Install pip temporarily for build steps, then remove it from the
production image.
2026-02-20 13:17:45 +01:00
lebaudantoine dac4a72838 🔒️(backend) uninstall pip in the production image
Reduce surface area and keep the runtime image minimal.
2026-02-20 13:17:45 +01:00
lebaudantoine 5048005fc1 🔧(tilt) use the same user as in production to facilitate testing
Use the same user as in production to facilitate local testing with
the production image.

Assign group 127 to the docker user to mirror CI and match production
practices, even though the rationale for this group mapping is unclear.
2026-02-20 13:17:45 +01:00
lebaudantoine 002c7c0e42 🩹(tilt) fix minor indentation issue in the Tilt file
No functional impact, just a formatting cleanup.
2026-02-20 13:17:45 +01:00
Stephan Meijer e18b732776 ⬆️(ci) upgrade GitHub Actions workflow steps to latest versions
Update all GitHub Actions to their latest major versions for improved
performance, security patches, and Node.js runtime compatibility.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-20 11:49:14 +01:00
lebaudantoine ce9f812a7e 🔖(minor) bump release to 1.7.0 2026-02-19 12:37:26 +01:00
lebaudantoine b02591170f 🐛(frontend) configure missing participants shortcut
Configure missing shortcut in the frontend for the participant
side panel.

It was accidentally omitted while merging Cyril's changes.
2026-02-19 12:12:23 +01:00
lebaudantoine e58181f846 🧑‍💻(backend) configure the external application API
Configure the external application API across different Kubernetes setups
to enable seamless usage without repeated configuration
when iterating on endpoints.
2026-02-19 11:16:10 +01:00
lebaudantoine d37f47e82c (frontend) expose Windows app web link
Expose a Windows application web link requested by a partner who wraps Visio
inside a containerized Chrome application due to security concerns and limited
trust in video codecs.

This commit introduces a proof of concept implementation.
We plan to iterate on this approach and likely generalize it under a more
neutral lasuite meet naming in future revisions.
2026-02-19 10:17:06 +01:00
lebaudantoine db80c09c10 ⬆️(frontend) update prettier 2026-02-18 22:10:24 +01:00
lebaudantoine fd9f2a81ca ⬆️(dependencies) update js dependencies 2026-02-18 22:10:24 +01:00
unteem d865db5f1b 📝(doc) fix variable name 2026-02-18 21:45:26 +01:00
unteem 7cc5b2b961 📝(doc) fix env files for docker compose
remove unused env file
mount .env
2026-02-18 21:45:26 +01:00
Cyril c85977cb68 (frontend) add clickable settings general link in idle modal
helps users quickly disable idle warning from the right settings tab.
2026-02-18 15:17:37 +01:00
Ovgodd 3c3b4a32e3 (frontend) support additional shortcuts to broaden accessibility
Add support for additional shortcuts to broaden accessibility and
integration capabilities. Some of these are required to ensure full
functionality with the RENATER SIP media gateway, allowing shortcut
mapping to DTMF signals. Others improve usability for keyboard-only
users; a lightweight helper will be introduced to surface available
shortcuts and make them easier to discover and use.
2026-02-12 18:56:48 +01:00
Ovgodd 9b033c55b2 (frontend) support Shift and Alt key when building shortcuts
Add support for Shift and Alt modifiers when building shortcuts,
expanding the range of possible combinations and allowing more expressive
and flexible shortcut definitions.
2026-02-12 18:56:48 +01:00
Ovgodd a2c7becaf4 ♻️(frontend) centralize shortcuts in a catalog
Centralize shortcuts into a single source of truth, making them easier to
discover and manage, and laying the groundwork for future override support
and the ability to revert to default definitions if needed.

Shortcuts are now retrieved by identifier, while leaving each component
responsible for declaring when a shortcut should be enabled and which
handler should be called;
2026-02-12 18:56:48 +01:00
lebaudantoine 89031abb63 🔖(minor) bump release to 1.6.0 2026-02-10 15:31:29 +01:00
Bastien Ogier fc92fa4eb4 🚀(docs) document Scalingo deployment
(docs) document Scalingo deployment
2026-02-10 10:44:13 +01:00
Bastien Ogier 2c65cc061e 🚀(settings) standardize DATABASE_URL environment retrieval
(settings) standardize DATABASE_URL environment retrieval
2026-02-10 10:44:13 +01:00
Bastien Ogier bfadeae6ee 🚀(scalingo) custom logo override
(scalingo) custom logo override
2026-02-10 10:44:13 +01:00
Sylvain Zimmer 117677bd14 🚀(paas) add PaaS deployment scripts, tested on Scalingo
add PaaS deployment scripts, tested on Scalingo
2026-02-10 10:44:13 +01:00
lebaudantoine 69c6e58017 🔒️(backend) add application validation when consuming external JWT
Token generation already verifies that the application is active, but this
guarantee was not enforced when the token was used. This change adds a
runtime check to ensure the client_id claim matches an existing and active
application when evaluating permissions.

This also introduces an emergency revocation mechanism, allowing all previously
issued tokens for a given application to be invalidated if the application is
disabled.
2026-02-09 22:18:09 +01:00
lebaudantoine 6742f5d19d (backend) monitor throttling rate failure through sentry
Use a mixin, introduced by @lunika in the shared
backend library to monitor throttling behavior.

The mixin tracks when throttling limits are reached, sending errors to Sentry
to trigger alerts when configured. This helps detect misconfigurations,
fine-tune throttling settings, and identify suspicious operations.

This enables safely increasing API throttling limits while ensuring stability,
providing confidence that higher limits won’t break the system.
2026-02-09 15:50:53 +01:00
lebaudantoine 23de7e52bc ♻️(backend) extract throttling classes into a module
Extract throttling classes into a dedicated Python module, following the
structure of suitenumerique/docs.

This is a preparatory refactor to ease upcoming changes to the throttling
implementation. No functional behavior change is introduced in this commit.
2026-02-09 15:50:53 +01:00
lebaudantoine 3887255e9c ♻️(backend) rework permission to better align with DRF responsibilities
If a viewset action is not implemented, the permission layer no longer returns
a 403. Instead, it lets DRF handle the request and return the appropriate 405
Method Not Allowed response, ensuring cleaner and more standard API error
handling.
2026-02-09 12:16:12 +01:00
lebaudantoine 5d6ad3f3f6 🔒️(backend) enhance scope manipulation
Enhance scope manipulation by normalizing and sanitizing
scope values before processing.

Scopes are now converted to lowercase to ensure consistent behavior,
deduplicated while preserving their original order, and handled in a
deterministic way aligned with the intended authorization model.
2026-02-09 12:16:12 +01:00
lebaudantoine 44d68a9c80 (backend) strengthen external API viewset test coverage
Reinforce the test suite around the external API viewset to better
prevent regressions, permission leaks, and unexpected failures.

Adds additional scenarios covering permission enforcement, edge cases,
and error handling to ensure the external API behavior remains stable
and secure as it evolves.
2026-02-09 12:16:12 +01:00
lebaudantoine ed5c1bbd84 ♻️(backend) improve scope prefix removal logic
The previous replace usage was too broad and could remove multiple
occurrences, which was not the original intention.

Replace the replace call with removeprefix, which more accurately
matches the expected behavior by only removing the prefix when present
at the start of the string.
2026-02-09 12:16:12 +01:00
lebaudantoine f8c6da8021 🔐(backend) enforce object-level permission checks on room endpoint
Apply strict permission validation on the external API room endpoint to
enforce the principle of least privilege. Unlike the default API (which allows
unauthenticated room retrieval and filters access in the serializer), the
external API now only exposes rooms to users with explicit permissions.

This change fixes a security issue. Slug-based room retrieval, as supported
by the default API, is not introduced here but could be added later if needed.
Retrieving rooms by UUID is retained, as guessing a UUID is significantly harder
than a slug.

A dedicated permission class was created to avoid coupling permissions between
the default and external APIs. The external API enforces stricter access rules.

Access policies may be revisited based on user and integrator feedback. The
external API currently has no production usage.
2026-02-09 12:16:12 +01:00
lebaudantoine 5ba1657e00 🧪(backend) add test exposing rooms permission flaw in external API
Add a failing test demonstrating that a user can retrieve a room they
do not have access to when the room UUID is known.

This highlights an improper object-level permission verification in the
external API. While exploitation requires obtaining the target room
UUID, this still represents a security issue (BOLA / IDOR class
vulnerability) and must be fixed.

The test documents the expected behavior and will pass once proper
access filtering or permission checks are enforced.
2026-02-09 12:16:12 +01:00
René Fischer c28b8ba902 🌐(frontend) add missing DE translation for accessibility settings 2026-02-08 23:57:51 +01:00
lebaudantoine 6962367e18 🐛(backend) fix notification tests broken by renaming env var
SCREEN_RECORDING_BASE_URL was renamed to RECORDING_DOWNLOAD_BASE_URL.

The new variable supersedes the old one, which is temporarily kept for backward
compatibility. This test failure was missed because the local common file was
out of sync with common.dist.

Add the new variable with a default value of None to ensure a smooth
deprecation path when the old variable is removed.
2026-02-07 00:14:49 +01:00
Cyril 0bd57e8623 💄(frontend) clean up spinner styles
remove inline styles for better maintainability
2026-02-06 23:29:23 +01:00
Cyril 27f2023104 ️(frontend) add reduced-motion spinner fallback
show an hourglass when animations are reduced
2026-02-06 23:29:23 +01:00
lebaudantoine 44362eca23 📝(changelog) update changelog
Update changelog with PR's purpose
2026-02-05 19:16:02 +01:00
lebaudantoine c34a85699b ⬆️(backend) upgrade Django to address multiple high-severity CVEs
This update fixes several SQL injection vulnerabilities, including issues in
RasterField band index handling and crafted column aliases (notably in
QuerySet.order_by()), as reported in CVE-2026-1207, CVE-2026-1287, and
CVE-2026-1312.
2026-02-05 19:16:02 +01:00
lebaudantoine 12d8c4a9db ️(admin) improve recording access select component performance
Replace the basic select component that loaded thousands of options into the
DOM with a smarter component supporting dynamic loading and search.

With large user bases, linking users to recording access caused massive option
lists to render, severely impacting performance. This change dramatically
improves page loading speed.
2026-02-05 19:16:02 +01:00
lebaudantoine 42a05da5c0 🔒️(admin) make recording fields read-only for security and performance
These values should not be updated from the admin interface. Allowing changes
to a recording’s associated room could lead to data leaks (e.g., notifications
being resent to the wrong users after a malicious modification).

Also remove the room select field, which rendered a dropdown with ~150k options,
flooding the DOM and severely degrading page performance.
2026-02-05 19:16:02 +01:00
lebaudantoine 4344dd6e35 ️(admin) optimize room view queries by prefetching user access
Use prefetch_related for the room–user access relationship to avoid N+1
queries. select_related cannot be used here since this is a many-to-many
relation. This significantly improves performance.
2026-02-05 19:16:02 +01:00
lebaudantoine fe28902b2e ️(admin) optimize recording view by selecting room at the SQL level
Use select_related on the room foreign key to avoid N+1 queries. This makes
Django perform a join between tables instead of triggering additional queries
per row, reducing complexity from O(n²) patterns to O(n) and significantly
improving performance.
2026-02-05 19:16:02 +01:00
lebaudantoine 1e1e1a2657 ️(admin) remove list filters based on room in recording view
This was a mistake: the filter was never used in production and caused
performance issues. It generated a list of unique room slugs, bloating the DOM
with thousands of values and slowing down view rendering. Remove this
regression.
2026-02-05 19:16:02 +01:00
lebaudantoine f4e48dafac 📝(frontend) update legal terms
Update legal terms following review and validation by the legal team.
2026-02-05 19:09:12 +01:00
lebaudantoine 9f58efb851 🥅(summary) catch file-related exceptions when handling recording objects
Previously, if a recording file was not found in the bucket, the code would
crash. This adds proper error handling to avoid unhandled failures.
2026-02-05 17:50:35 +01:00
Cyril 716e11b5b3 ️(frontend) fix form labels and autocomplete wiring
Ensure labels map to inputs and avoid empty describedby output
2026-02-04 09:28:15 +01:00
lebaudantoine 88a1136dfd ♻️(backend) refactor ApplicationViewSet to use a basic ViewSet
This endpoint only exposes a custom action for token generation and does not
rely on serializers or querysets. Using ViewSet is more appropriate here, as
it provides routing without enforcing standard CRUD patterns or requiring a
serializer_class.

This removes unnecessary constraints and avoids warnings related to missing
serializer configuration, while better reflecting the actual responsibility of
this view.

I noticed this bug from Sentry issue 241308
2026-02-03 16:22:06 +01:00
lebaudantoine 90633928a8 💚(backend) reactivate trivy scan on backend image
Protobuff has been patched, rebuilding the backend image should be
enough with pip to pull its latest version, which fixes the CVE.
2026-02-03 11:57:02 +01:00
lebaudantoine fd894eb61f 🔧(compose) configure LiveKit webhooks in the local Docker Compose stack
Without this configuration, LiveKit does not notify the backend when a recording
starts, leaving it stuck in a “starting recording” state.

Thanks to @leobouloc for spotting the issue.
2026-01-29 18:22:00 +01:00
lebaudantoine bb64532cff 🔖(minor) bump release to 1.5.0 2026-01-28 21:28:55 +01:00
Cyril 692c55ed1b Merge branch 'refactor/issue-921-generic-sr-announcer' 2026-01-28 17:07:43 +01:00
lebaudantoine df616ae711 🩹(doc) fix github rendering of docker compose doc
The docker compose rendering was broken because of a recent merge.
Fix it. I've also fixed other minor issues.
2026-01-28 16:17:53 +01:00
Cyril 021d7a7e06 ️(frontend) centralize aria-live announcements in store
avoid per-feature live regions and reduce a11y duplication.
2026-01-28 14:01:35 +01:00
Andrew Hunter f2a3e7c8de 📝(doc) Fix typo 2026-01-28 12:13:19 +01:00
Andrew Hunter cf07ceb67e 🔧(docker) Fix incorrect env variable
Incorrect capitalization prevents correct MEET_HOST variable
subsitution.
2026-01-28 12:13:19 +01:00
Andrew Hunter ea7fb5fc27 📝(doc) Use an empty directory for postgres
Use an empty directory for postgres data, otherwise it will complain the
directory is not empty and fail to start.
2026-01-28 12:13:19 +01:00
Andrew Hunter 6e8a6ce82a 📝(doc) Add -p swich to mkdir
Add the -p switch to create the parent directory before we try to cd
into it.
2026-01-28 12:13:19 +01:00
Andrew Hunter ce960ae330 📝 (doc) Add key gen example
Add a API key generation example using OpenSSL.
2026-01-28 12:13:19 +01:00
Cyril f9dd2e1909 ️(frontend) add global screen reader announcer
centralize live region rendering with a shared announce hook.
2026-01-28 11:44:39 +01:00
Cyril 9023e54352 ️(frontend) add screen reader announcer store
create shared state for screen reader announcements.
2026-01-28 11:40:54 +01:00
Cyril 8295574616 (frontend) sr pin/unpin announcements with dedicated messages
improves accessibility by announcing pin/unpin on state change
2026-01-28 11:13:09 +01:00
Cyril db15c8b6cc ️(frontend) adjust visual-only tooltip a11y labels
Ensure tooltips stay visual while exposing correct aria-labels.
2026-01-28 10:08:01 +01:00
Cyril e1aeec6053 ️(frontend) adjust sr announcements for idle disconnect timer
reduces screen reader noise while keeping key countdown cues
2026-01-27 22:12:55 +01:00
lebaudantoine c5aa762e11 📝(doc) update mosacloud link in the list of saas instances
Link has changed. Update it.
2026-01-27 18:38:34 +01:00
lebaudantoine 8f710a4626 🔒️(frontend) fix an XSS vulnerability on the recording page
An XSS vulnerability was identified by an open-source contributor. While the
impact was limited, only a room owner could inject the content and then view the
recording page, it is important to address, especially before introducing
multi-owner support.
2026-01-27 14:12:45 +01:00
virgile-deville 60d1338eff 📝(readme) mention french state wide deployment
To indicate product maturity to reusers

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-01-26 12:04:16 +01:00
lebaudantoine f8436d9ae2 🔖(minor) bump release to 1.4.0 2026-01-25 20:02:37 +01:00
lebaudantoine 39fb273201 💩(ci) disable temporarily Trivy scan step for backend image
A new vulnerability (CVE-2026-0994) was reported and is not yet fixed.
It affects protobuf libraries used by the livekit-api Python package.

A fix is in progress upstream, but the related PR has not yet been merged or
released. Since a release is required tonight, the Trivy scan step is
temporarily disabled to allow the build to proceed. This should be re-enabled
once a patched version is available.

https://github.com/protocolbuffers/protobuf/pull/25239
2026-01-25 18:01:13 +01:00
lebaudantoine d101459115 (frontend) add configurable external redirect for unauthenticated users
Offer a way to redirect unauthenticated users to an external home page when they
visit the app, allowing a more marketing-focused entry point with a clearer
value proposition.

In many self-hosted deployments, the default unauthenticated home page is not
accessible or already redirects elsewhere. To ensure resilience, the client
briefly checks that the target page is reachable and falls back to the default
page if not.
2026-01-25 16:49:56 +01:00
aleb_the_flash 88696a23fd 🩹(doc) update link to the environment variables
Link was invalid. Update it to point to the chart's README file.
Please note this file might be removed.
2026-01-25 00:17:50 +01:00
Cyril 13d26a76b3 (frontend) scope scrollbar gutter override to video rooms
limit scrollbar gutter override to video conference context
2026-01-25 00:07:51 +01:00
lebaudantoine b675517a60 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:43:29 +01:00
lebaudantoine a5254ffd59 🔊(frontend) log participant and segments
Log transcription segments to troubleshoot duplication issue.
2026-01-23 18:53:10 +01:00
lebaudantoine ff82bca9ec 🐛(frontend) ensure transcript segments are sorted by their timestamp
Switching from Deepgram to our custom Kyutai implementation introduced changes
in how segment data is returned by the LiveKit agent, so the segment start time
is now treated as optional.
2026-01-23 18:22:40 +01:00
lebaudantoine 99a18b6e90 🩹(backend) use case-insensitive email matching in the external api
Fix a minor issue in the external API where users were matched using
case-sensitive email comparison, while authentication treats emails as
case-insensitive. This caused inconsistencies that are now resolved.

Spotted by T. Lemeur from Centrale.
2026-01-20 20:50:13 +01:00
Cyril 250e599465 📝(frontend) align close dialog label in rooms locale
keep close label consistent with global wording
2026-01-20 12:39:03 +01:00
Cyril 144a4e1b85 ️(frontend) improve background effect announcements
ensure sr announces clear and virtual background state
2026-01-20 12:34:32 +01:00
Cyril 78ab3cdbdf ️(frontend) improve aria-label with accessible emoji description
replace raw emoji with descriptive label to enhance screen reader support
2026-01-19 23:35:18 +01:00
Cyril a815d6c00d 📝(docs) add changelog file to document project changes
helps track notable changes and improvements over time
2026-01-19 23:35:18 +01:00
Cyril dfbc3a9d17 💄(frontend) add globally available sr-only utility class
provides reusable hidden style for screen reader-only content
2026-01-19 23:35:18 +01:00
Cyril 086db3d089 📝(frontend) update a11y store labels and link for clarity
improves naming and navigation for better user understanding of options
2026-01-19 23:35:18 +01:00
Cyril 014ef3d804 (frontend) create a11y store to manage user option toggles
sets up state handling for enabling or disabling a11y preferences
2026-01-19 23:35:18 +01:00
Cyril de3e1a56a8 (frontend) add placeholder for accessibility menu in settings panel
prepares UI for future accessibility options without implementing logic yet
2026-01-19 23:35:18 +01:00
Cyril 459749b992 (frontend) getEmojiLabel util for accessible emoji labeling across app
centralizes emoji label logic to ensure consistency and reuse in UI components
2026-01-19 23:35:18 +01:00
Cyril e1450329f2 ️(frontend) add screen reader announcements for reactions interactions
ensures users get feedback when adding reactions via assistive tech
2026-01-19 23:35:18 +01:00
Cyril c7e3194331 ️(frontend) announce copy state in invite dialog
improves screen reader feedback after copying the link
2026-01-19 22:55:47 +01:00
Cyril 902b005f32 ️(frontend) improve contrast for selected options
add dark inner border to enhance visibility and accessibility
2026-01-19 22:28:46 +01:00
Cyril 51d22783b2 ️(frontend) make carousel image decorative
avoid screen reader announcing redundant visual content
2026-01-19 18:29:25 +01:00
blipp 76f80a0f2f Fix k8s link in Docker Compose installation guide 2026-01-19 18:29:25 +01:00
Cyril 82eb930200 📝(docs) update changelog
document the latest change in the project history
2026-01-19 18:29:25 +01:00
Cyril eeeb950e08 ️(frontend) improve participants toggle a11y label
avoid screen reader duplication by using visual-only tooltip
2026-01-19 18:29:19 +01:00
Cyril cb77688572 ️(frontend) add accessible back button in side panel
label the back button and separate it from the heading for a11y
2026-01-19 15:14:25 +01:00
lebaudantoine f9524b2f0a 🔒️(backend) prevent automatic upgrade setuptools
The latest `setuptools` version pulls in a `jaraco.context` version that
triggers a Trivy scan failure. `jaraco.context` has a path traversal
vulnerability.

This fix is inspired by suitenumerique/people, specifically Marie’s PR #1010.
2026-01-19 14:16:00 +01:00
lebaudantoine a50aabeaf8 🔖(minor) bump release to 1.3.0 2026-01-13 15:44:23 +01:00
lebaudantoine 594bd5a692 🚸(frontend) hide back button when a user is ejected by an admin
Avoid showing a back button when a user is kicked out of a meeting by an admin,
to prevent them from repeatedly rejoining the room.
2026-01-13 15:28:39 +01:00
lebaudantoine 69d92e6f30 🩹(frontend) icon font loading to avoid text/icon flickering
Icon fonts were loading just in time, which is good for performance, but caused
a visible blink where fallback text appeared before the font loaded. I followed
the documentation introduced in PR 963 of the fontsource repository.

This introduces preloading for critical fonts, slightly increases initial load
time, and defines custom @font-face rules to control font-display and avoid
font swapping. This approach only works with Vite-based frameworks,
as noted in the documentation.

See the advanced installation section for material-symbols-outlined on
fontsource.org, and apply the same approach for Material Icons.

I manually built the preload headers based on a comment from issue #83.
This works well with Vite, which replaces the font URLs at build time.
2026-01-12 12:56:08 +01:00
lebaudantoine c47e830b40 ♻️(frontend) introduce an Icon primitive
Encapsulate icon and symbol rendering in a dedicated component that applies
aria-hidden and disables translation attributes.

This prevents browsers from translating icon names and breaking the UI, and
ensures screen readers do not announce decorative icons.

This is a first draft and can be extended with additional variants later.
2026-01-12 12:56:08 +01:00
lebaudantoine d7f1b7b94c 🚸(frontend) explain to a user her was ejected
Add a clear feedback message explaining to users when they are ejected from a
meeting, explicitly stating that the action was taken by an admin.
2026-01-11 23:07:54 +01:00
lebaudantoine 8072d2c950 ♻️(frontend) refactor disconnection reason handling and state
Refactor the duplicateIdentity boolean URL parameter into an extensible string
reason parameter, making it easier to customize the disconnection message
shown to users.

Avoid passing this value via URL parameters, which are easy to manipulate.
Instead, use Wouter’s built-in navigation state to pass data across pages.

This was initially missed because navigateTo is a wrapper around Wouter’s
official navigation function, and its arguments were easy to overlook. This is
now fixed.

This prepares the ground for supporting additional disconnection reasons in
upcoming commits.
2026-01-11 23:07:54 +01:00
lebaudantoine 726f9097f9 ♻️(frontend) refactor the onDisconnected function to use a switch
This makes the logic more extensible in preparation for introducing
additional disconnect reason handlers.
2026-01-11 23:07:54 +01:00
Cyril bbc7fa8012 ️(frontend) focus first background effect button on panel open
improves keyboard navigation by placing focus on first actionable element
2026-01-09 19:03:34 +01:00
Cyril 41db3e766b ️(frontend) add blur status with sr announcement and sr-only class
improves a11y by exposing blur state to sr users and hiding visual labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-09 19:03:34 +01:00
Cyril 1ab3ce6d47 ️(frontend) improve background effects a11y and blur labels
Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-09 19:03:34 +01:00
lebaudantoine 3cd5c77f42 ️(frontend) enhance sidepanel accessibility
Use the appropriate HTML <aside> element for the side panel and enhance
it with the correct ARIA attributes to improve accessibility.
2026-01-09 19:03:34 +01:00
lebaudantoine 3ddb075c6b ️(frontend) enhance vocalization of blur options
Hide non-essential icons and refine the labels to emphasize that one option
applies a stronger blur than the other. This should provide clearer cues for
screen reader users.
2026-01-09 19:03:34 +01:00
lebaudantoine 9ed2500565 🚸(frontend) remove the “none” effect button
While this makes it slightly less explicit that clicking an already selected
option will disable the active effect, it improves accessibility by avoiding
automatic focus movement from the previously active option to a separate “none”
option. That focus shift could be misleading or hard to follow
for screen reader users.

Open to feedback on this decision.
2026-01-09 19:03:34 +01:00
lebaudantoine 1001783d3c ️(frontend) enhance vocalized indication of virtual background
Update the virtual background effects tooltip and ARIA label with more
descriptive and concise wording based on Sophie’s feedback. This helps all
users, especially those using assistive technologies, by improving how
each virtual background is vocalized.
2026-01-09 19:03:34 +01:00
lebaudantoine 97b5e8780c 🩹(frontend) fix minor layout issue hidding focus ring
Fix an issue where the focus visual indication was hidden due to an overly tight
layout with no padding on the background and effects toggle buttons.
2026-01-09 19:03:34 +01:00
lebaudantoine 7c7074aa99 🚸(frontend) refine effects wording
Refine the Effects title to clearly indicate it covers both background and
effects, improving clarity. Inspired by Google Meet.
2026-01-09 19:03:34 +01:00
lebaudantoine 35b3bcad63 🔧(agents) make Silero VAD optional
Allow configuring whether a VAD model runs before calling an external ASR API.
Running VAD can save API calls (and costs) when no audible sound is detected,
but comes with the trade-off of additional computational overhead.
2026-01-08 18:03:23 +01:00
lebaudantoine 137a2c7f6f 🩹(frontend) close subtitles on room disconnections
Subtitles were still visible when leaving and rejoining a meeting, even though
the backend API call to start them was not triggered again.

Introduce a hook that closes the subtitles layout on unmount, ensuring users
must explicitly click the button to restart subtitles when they rejoin a room.
2026-01-08 15:13:37 +01:00
lebaudantoine d681e25bcc 💄(frontend) adjust spacing in the recording side panels
Based on @Arnaud’s feedback, adjust the spacing between the title, details
section, and control buttons to make the layout feel more homogeneous.
2026-01-08 13:17:46 +01:00
lebaudantoine 1f1a6371b4 🚸(frontend) remove the default comma delimiter in humanized durations
The comma caused values like 1h30 to be rendered as “1 heure, 30 minutes,”
which feels awkward in most European languages.
2026-01-08 13:17:46 +01:00
Cyril bbfbb23be5 ♻️(frontend) extract tools panel focus logic into reusable hook
prepares logic reuse for consistent focus restoration across the app
2026-01-07 14:50:45 +01:00
Cyril 6e20bc1f43 ️(frontend) restore focus to trigger button when panel closes
improves keyboard navigation and accessibility consistency

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine fed05f2396 ️(frontend) fix jump and animation break on panel open with auto-focus
used requestAnimationFrame and preventScroll to preserve smooth transition

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine 865acf2838 (frontend) focus transcript and record buttons on open
move keyboard focus to transcript or recording button when the panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine 6ae68013af (frontend) add SR announcements for transcript and recording
announce transcript and record events to sr to provide clear feedback

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine 394a1be322 (frontend) add sr-only class
add a utility class to hide content visually while keeping it available to sr.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
Cyril a71a1fd968 📝(docs) add changelog entry for visio button tooltip a11y fix
documents fix ensuring tooltip appears only on keyboard nav
2026-01-07 12:55:51 +01:00
Cyril 40af264562 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
fixes accidental tooltip trigger unrelated to visio screen interaction
2026-01-07 12:44:52 +01:00
Arnaud Robin 8b2d06976e 📝(terms) update terms of service
Enhance the terms of service by adding detailed sections on
service availability, security, support management,
and monitoring of service use. This update aims to provide clearer
guidelines and responsibilities for users and the DINUM administration.
2026-01-06 22:57:04 +01:00
lebaudantoine 58313666ed 👷(ci) ignore trivy scan output temporary
CVE-2025-13601 has yet no fix. I don't want to migrate the base image
in this pull request, as it could introduce regression.

I'll open an issue to fix this CVE later on. The summary service isn't
exposed on internet, and the agent isn't used in production.
2026-01-06 19:49:23 +01:00
lebaudantoine f3c8aec189 🔧(ci) add trivy scans for summary and agent
Closes #685: add a Trivy scan to the CI build steps for Meet Summary
and Meet Agents to ensure no vulnerabilities are present before pushing images
to the registry.
2026-01-06 19:49:23 +01:00
lebaudantoine 0a0c7ba618 (summary) add dutch and german languages
Based on a request from our European partners, introduce new languages for the
transcription feature. Dutch and German are now supported, which is a great
addition.

It closes #837.

WhisperX is expected to support both languages.
2026-01-06 17:52:04 +01:00
renovate[bot] d7ad5aed05 ⬆️(dependencies) update aiohttp to v3.13.3 [SECURITY] 2026-01-06 17:00:00 +01:00
lebaudantoine 4acc9cf40d 🩹(frontend) render the NoAccessView for unprevileged users
Simplify a broken conditional check that allowed users without
the required permissions to see the control menu. The `NoAccessView`
is now shown to any user who is neither an admin nor the meeting owner.
2026-01-06 16:43:15 +01:00
lebaudantoine 13d0d3d801 📈(frontend) track metadata on recording-started events
I introduced transcript + screen recording modes but forgot
to properly track them in PostHog. Fix this issue.
2026-01-06 16:43:15 +01:00
309 changed files with 19976 additions and 4932 deletions
+8 -6
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__
**/*.pyc
venv
.venv
**/.venv
# System-specific files
.DS_Store
@@ -24,13 +24,15 @@ data
.cache
.circleci
.git
.vscode
.iml
.idea
db.sqlite3
.mypy_cache
.pylint.d
.pytest_cache
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Frontend
node_modules
**/node_modules
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download Crowdin files
uses: crowdin/github-action@v2
+71 -5
View File
@@ -12,6 +12,9 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -20,10 +23,18 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -49,6 +60,7 @@ jobs:
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -56,10 +68,18 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -86,6 +106,7 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -93,10 +114,18 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -123,6 +152,7 @@ jobs:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -130,10 +160,18 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -147,6 +185,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@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@v6
@@ -154,6 +200,7 @@ jobs:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -161,10 +208,18 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -178,6 +233,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@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@v6
@@ -185,12 +248,15 @@ jobs:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
+98 -32
View File
@@ -7,14 +7,18 @@ on:
pull_request:
branches:
- "*"
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@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: show
@@ -39,9 +43,11 @@ jobs:
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@v4
uses: actions/checkout@v6
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -49,9 +55,11 @@ jobs:
lint-changelog:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -62,20 +70,22 @@ jobs:
build-mails:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -95,43 +105,49 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
uses: actions/cache@v5
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@v4
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
run: uv run 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@v4
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
@@ -144,14 +160,16 @@ jobs:
lint-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
@@ -165,7 +183,8 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
run:
working-directory: src/backend
@@ -213,10 +232,11 @@ jobs:
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com
MEDIA_BASE_URL: http://localhost:8083
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create writable /data
run: |
@@ -224,7 +244,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -258,13 +278,13 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages)
run: |
@@ -272,16 +292,58 @@ jobs:
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
APP_API_TOKEN: "test-api-token"
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install dependencies
run: cd src/frontend/ && npm ci
@@ -294,12 +356,14 @@ jobs:
lint-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
@@ -312,13 +376,15 @@ jobs:
build-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+29
View File
@@ -0,0 +1,29 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+1
View File
@@ -31,6 +31,7 @@ MANIFEST
# Translations # Translations
*.pot
*.mo
# Environments
.env
+217 -2
View File
@@ -8,6 +8,221 @@ and this project adheres to
## [Unreleased]
### Changed
- ⚡️(helm) Add pod and container securityContext #4805
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
- ♿️(frontend) fix more tools heading hierarchy #1181
- ♿️(fronted) improve button descriptions for More tools actions #1184
- 💄(spinner) enforce spinner height #1183
- 💄(custom-background) add upload indicator with preview #1183
- ♿️(backend) improve logo accessibility in recording email notification #1092
- ♿️(summary) improve accessibility of transcription download link #1187
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
- ⬆️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
- ⬆️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
- ⬆️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
### Fixed
- 🐛(frontend) disable personal custom background while deleting #1183
- 🐛(frontend) auto-select new custom background when not logged in #1183
- 🐛(frontend) fix device selection not applying during conference #1156
## [1.11.0] - 2026-03-19
### Added
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿️(frontend) improve chat toast a11y for screen readers #1109
- ♿️(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
- 📌(agents) unpin OpenSSL and related dependencies #1167
- ♿️(frontend) add caption font and background color customization #1122
### Fixed
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
- ♿(frontend) dynamic tab title when connected to meeting #1060
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
### Added
- ✨(backend) add file upload feature #1030
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
- 🔒️(frontend) uninstall curl from the frontend production image #987
- 💄(frontend) add focus ring to reaction emoji buttons
- ✨(frontend) introduce a shortcut settings tab #975
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20
### Changed
- 🔒️(agents) uninstall pip from the agents image
- 🔒️(summary) switch to Alpine base image
- 🔒️(backend) uninstall pip in the production image
### Fixed
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
## [1.7.0] - 2026-02-19
### Added
- ✨(frontend) expose Windows app web link #976
- ✨(frontend) support additional shortcuts to broaden accessibility
### Changed
- ✨(frontend) add clickable settings general link in idle modal #974
- ♻️(backend) refactor external API token-related items #1006
## [1.6.0] - 2026-02-10
### Added
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
### Changed
- ♿️(frontend) improve spinner reducedmotion fallback #931
- ♿️(frontend) fix form labels and autocomplete wiring #932
- 🥅(summary) catch file-related exceptions when handling recording #944
- 📝(frontend) update legal terms #956
- ⚡️(backend) enhance django admin's loading performance #954
- 🌐(frontend) add missing DE translation for accessibility settings
### Fixed
- 🔐(backend) enforce object-level permission checks on room endpoint #959
- 🔒️(backend) add application validation when consuming external JWT #963
## [1.5.0] - 2026-01-28
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
- ♿️(frontend) add global screen reader announcer#922
### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
## [1.4.0] - 2026-01-25
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
### Added
@@ -27,7 +242,7 @@ and this project adheres to
## [1.1.0] - 2025-12-22
### Added
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
@@ -39,6 +254,6 @@ and this project adheres to
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+45 -24
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
@@ -13,14 +13,28 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
# Copy required python dependencies
COPY ./src/backend /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN mkdir /install && \
pip install --prefix=/install .
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:20 AS mail-builder
@@ -30,7 +44,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -39,19 +53,20 @@ ARG MEET_STATIC_ROOT=/data/static
RUN apk add \
pango \
libmagic \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -68,6 +83,7 @@ RUN apk --no-cache add \
gettext \
libffi-dev \
pango \
libmagic \
shared-mime-info
@@ -79,14 +95,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -101,10 +120,9 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y meet
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
@@ -113,7 +131,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
@@ -127,6 +145,9 @@ ARG MEET_STATIC_ROOT=/data/static
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+18 -9
View File
@@ -23,9 +23,10 @@
# ==============================================================================
# VARIABLES
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
ESC := $(shell printf '\033')
BOLD := $(ESC)[1m
RESET := $(ESC)[0m
GREEN := $(ESC)[1;32m
# -- Database
@@ -85,7 +86,8 @@ bootstrap: \
demo \
back-i18n-compile \
mails-install \
mails-build
mails-build \
run
.PHONY: bootstrap
# -- Docker/compose
@@ -112,7 +114,8 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
@@ -188,6 +191,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests
@@ -200,6 +204,11 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@@ -220,7 +229,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@@ -351,13 +360,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+2
View File
@@ -0,0 +1,2 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+4 -1
View File
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
### 🚀 Major roll out to all French public servants
On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents
@@ -86,7 +89,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
+5 -1
View File
@@ -70,7 +70,7 @@ docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
@@ -99,7 +99,11 @@ k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
ASSETS_DIR=build/frontend-out/assets
if [ -n "$CUSTOM_LOGO_URL" ]; then
# Ensure https
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
# Prevent SSRF
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
LOGO_FILE="${ASSETS_DIR}/logo.svg"
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
# Actual download
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
# Validate filesize
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
# Validate file type
IS_SVG=false
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
[[ "$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"
fi
mv src/backend/* ./
mv deploy/paas/* ./
echo "3.13" > .python-version
echo "." > requirements.txt
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+4 -2
View File
@@ -58,7 +58,7 @@ services:
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
exit 0;"
app-dev:
@@ -80,11 +80,11 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- postgresql
- mailcatcher
- redis
- nginx
- livekit
- createbuckets
- createwebhook
@@ -106,6 +106,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app-dev
@@ -148,6 +149,7 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- app-dev
networks:
- resource-server
- default
+52
View File
@@ -0,0 +1,52 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
server_tokens off;
root /app/build/frontend-out;
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
@@ -4,10 +4,47 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
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;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
add_header Content-Disposition "attachment";
}
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/files/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;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / {
proxy_pass http://keycloak:8080;
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-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
}
}
@@ -1,9 +1,9 @@
upstream meet_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_HOST}:8080 fail_timeout=0;
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
}
server {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.0
FROM livekit/livekit-server:v1.9.4
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
@@ -3,3 +3,8 @@ redis:
address: redis:6379
keys:
devkey: secret
webhook:
api_key: devkey
urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
+3 -2
View File
@@ -10,7 +10,7 @@ services:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
- ./data/databases/backend:/var/lib/postgresql/data
redis:
image: redis:5
@@ -20,8 +20,8 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- .env
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
@@ -45,6 +45,7 @@ services:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- .env
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
+5 -6
View File
@@ -8,7 +8,7 @@
### Step 1: Prepare your working environment:
```bash
mkdir keycloak/env.d && cd keycloak
mkdir -p keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
@@ -61,11 +61,10 @@ services:
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
Your keycloak instance is now available on https://id.yourdomain.tld
> [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
2. Copy the client ID (`meet` in this example) and the client secret.
+3 -3
View File
@@ -68,13 +68,13 @@ backend:
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
+4
View File
@@ -9,6 +9,10 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
## Scalingo
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
+15 -6
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
## Requirements
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
In this example, we assume the following services:
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports.
### Meet
@@ -129,7 +129,7 @@ The following ports will need to be opened:
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the follwoing:
If you are using ufw, enter the following:
```
ufw allow 80/tcp
ufw allow 443/tcp
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
# external: true
```
#### Caddy Reverse Proxy
Expose the Frontend port to the host
```yaml
frontend:
ports:
- "8086:8086"
```
## Step 5: Start Meet
You are ready to start your Meet application !
@@ -198,7 +207,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
+7 -7
View File
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed.
```
$ kubectl config set-context --current --namespace=meet
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
You can find these values in **examples/helm/keycloak.values.yaml**
### Find livekit server connexion values
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
Livekit need a redis (and meet too) so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
```
$ helm repo add livekit https://helm.livekit.io
$ helm repo update
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 30m
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 45m
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
```
$ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
```
## Test your deployment
+185
View File
@@ -0,0 +1,185 @@
# Deployment on Scalingo
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
## Overview
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
## Prerequisites
- A Scalingo account
- Scalingo CLI installed (optional but recommended)
- A PostgreSQL database addon
- A Redis addon (for caching and sessions)
## Step 1: Create Your App
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
## Step 2: Provision Addons
Add the required PostgreSQL and Redis services.
This will set the following environment variables automatically:
- `SCALINGO_POSTGRESQL_URL` - Database connection string
- `SCALINGO_REDIS_URL` - Redis connection string
## Step 3: Configure Environment Variables
Set the following environment variables in your Scalingo app:
### Buildpack Configuration
```bash
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
scalingo env-set LASUITE_APP_NAME="meet"
scalingo env-set LASUITE_BACKEND_DIR="."
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
scalingo env-set LASUITE_NGINX_DIR="."
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
```
### Database and Cache
```bash
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
```
### Django Settings
```bash
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
scalingo env-set DJANGO_CONFIGURATION="Production"
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
```
### OIDC Authentication
Configure your OIDC provider (e.g., Keycloak, Authentik):
```bash
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
```
### LiveKit Configuration
Meet requires a LiveKit server for video conferencing:
```bash
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
```
### Email Configuration (Optional)
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
```bash
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
scalingo env-set DJANGO_EMAIL_PORT="587"
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
```
## Step 4: Deploy
Deploy your application:
```bash
git push scalingo main
```
The Procfile will automatically:
1. Build the frontend (Vite)
2. Build the backend (Django)
3. Run the post-compile script (cleanup)
4. Run the post-frontend script (move assets and prepare for deployment)
5. Start Nginx and Gunicorn
6. Run django migrations
## Step 5: Create superuser
After the first deployment, create an admin user:
```bash
scalingo run python manage.py createsuperuser
```
## Custom Domain (Optional)
To use a custom domain:
1. Add the domain in Scalingo dashboard
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
3. Configure your DNS to point to Scalingo
```bash
scalingo domains-add meet.yourdomain.com
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
```
## Custom Logo (Optional)
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
```bash
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
```
## Troubleshooting
### Check Logs
```bash
scalingo logs --tail
```
### Common Issues
1. **Build fails**: Check that all required environment variables are set
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
### Useful Commands
```bash
# Open a console
scalingo run bash
# Restart the app
scalingo restart
# Scale containers
scalingo scale web:2
# One-off command
scalingo run python manage.py shell
```
## Architecture
On Scalingo, the application runs as follows:
1. **Build Phase**: The buildpack compiles both frontend and backend
2. **Runtime**:
- Nginx serves static files and proxies to the backend
- Gunicorn runs the Django WSGI application
- Both processes are managed by the `bin/buildpack_start.sh` script
## Additional Resources
- [Scalingo Documentation](https://doc.scalingo.com/)
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
- [Meet Environment Variables](../../src/helm/meet/README.md)
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
+3
View File
@@ -23,9 +23,12 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+1 -1
View File
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
+1
View File
@@ -2,6 +2,7 @@
Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import re
+21
View File
@@ -3,6 +3,21 @@
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"groupName": "js dependencies",
"matchManagers": ["npm"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict"
},
{
"groupName": "python dependencies",
"matchManagers": ["setup-cfg", "pep621"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days"
},
{
"enabled": false,
"groupName": "ignored python dependencies",
@@ -15,6 +30,12 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+3
View File
@@ -19,6 +19,9 @@ FROM base AS production
WORKDIR /app
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
ARG DOCKER_USER
USER ${DOCKER_USER}
+5 -4
View File
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider():
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+3 -2
View File
@@ -1,14 +1,15 @@
[project]
name = "agents"
version = "1.2.0"
version = "1.11.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1"
"python-dotenv==1.2.1",
"protobuf==6.33.5"
]
[project.optional-dependencies]
+52 -4
View File
@@ -115,6 +115,10 @@ class RoomAdmin(admin.ModelAdmin):
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
@@ -138,6 +142,7 @@ class RecordingAccessInline(admin.TabularInline):
model = models.RecordingAccess
extra = 0
autocomplete_fields = ["user"]
@admin.action(description=_("Resend notification to external service"))
@@ -192,6 +197,38 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
)
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
def mark_as_failed_to_stop(modeladmin, request, queryset):
"""Force selected recordings status to failed_to_stop."""
eligible_statuses = [
models.RecordingStatusChoices.ACTIVE,
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.STOPPED,
]
eligible = queryset.filter(status__in=eligible_statuses)
skipped = queryset.exclude(status__in=eligible_statuses).count()
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
if updated > 0:
modeladmin.message_user(
request,
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
% {"count": updated},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s recording(s) with an ineligible status.")
% {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
@@ -207,9 +244,19 @@ class RecordingAdmin(admin.ModelAdmin):
"created_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
actions = [resend_notification]
list_filter = ["created_at"]
list_select_related = ("room",)
readonly_fields = (
"id",
"created_at",
"options",
"mode",
"room",
"status",
"updated_at",
"worker_id",
)
actions = [resend_notification, mark_as_failed_to_stop]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -261,7 +308,7 @@ class ApplicationAdmin(admin.ModelAdmin):
form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display")
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
fields = [
"name",
"id",
@@ -270,6 +317,7 @@ class ApplicationAdmin(admin.ModelAdmin):
"scopes",
"client_id",
"client_secret",
"is_active",
]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline]
+15
View File
@@ -43,6 +43,21 @@ def get_frontend_configuration(request):
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_count_by_user"
],
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_size"
],
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_extensions"
],
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_mimetypes"
],
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
+1
View File
@@ -13,6 +13,7 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
}
@classmethod
+67
View File
@@ -0,0 +1,67 @@
"""API filters for meet' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from django_filters import BooleanFilter
from core import models
class FileFilter(django_filters.FilterSet):
"""
Custom filter for filtering files.
"""
class Meta:
model = models.File
fields = ["type"]
class ListFileFilter(FileFilter):
"""Filter class dedicated to the file viewset list method."""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted")
class Meta:
model = models.File
fields = ["is_creator_me", "type", "upload_state", "is_deleted"]
def filter_is_deleted(self, queryset, name, value):
"""
Filter files based on whether they are deleted or not.
Example:
- /api/v1.0/files/?is_deleted=false
→ Filters files that were not deleted
"""
if value is None:
return queryset
lookup = "__".join([name, "isnull"])
return queryset.filter(**{lookup: not value})
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
"""
Filter files based on the `creator` being the current user.
Example:
- /api/v1.0/files/?is_creator_me=true
→ Filters files created by the logged-in user
- /api/v1.0/files/?is_creator_me=false
→ Filters files created by other users
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(creator=user)
return queryset.exclude(creator=user)
+30
View File
@@ -1,5 +1,8 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices
@@ -106,3 +109,30 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
class FilePermission(IsAuthenticated):
"""
Permissions applying to the file API endpoint.
Handling soft deletions specificities
"""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
if not settings.FILE_UPLOAD_ENABLED:
raise Http404
return super().has_permission(request, view)
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted files or if the user is not the owner
"""
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
raise Http404
if obj.creator != request.user:
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
+235 -11
View File
@@ -1,16 +1,27 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
import logging
from os.path import splitext
from typing import Literal
from urllib.parse import quote
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
logger = logging.getLogger(__name__)
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
@@ -23,6 +34,15 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class ResourceAccessSerializerMixin:
"""
A serializer mixin to share controlling that the logged-in user submitting a room access object
@@ -201,6 +221,27 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class RecordingOptions(BaseModel):
"""Configuration options for recording.
Attributes:
language: ISO 639-1 language code compatible with whisperX.
When `None`, the transcription engine will attempt to
auto-detect the spoken language.
transcribe: Whether to transcribe the recorded audio.
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
"""
language: str | None = None
transcribe: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests."""
@@ -213,10 +254,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
options = SchemaField(
schema=RecordingOptions | None,
required=False,
allow_null=True,
default=dict,
help_text="Recording options",
)
@@ -261,6 +303,28 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
Control what a participant is allowed to publish, subscribe, and do within a room.
Unknown fields are rejected.
"""
can_subscribe: bool | None = None
can_publish: bool | None = None
can_publish_data: bool | None = None
can_publish_sources: list[int] = Field(
default_factory=list
) # TrackSource enum values
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
agent: bool | None = None
can_subscribe_metrics: bool | None = None
model_config = {"extra": "forbid"}
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -272,10 +336,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
permission = SchemaField(
schema=ParticipantPermission | None,
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
help_text="Participant permissions",
)
name = serializers.CharField(
max_length=255,
@@ -285,6 +350,33 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant",
)
def validate_permission(self, permission):
"""Validate that the given permission does not include forbidden or unimplemented fields."""
if permission is None:
return None
suspicious_fields = [
field
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
if getattr(permission, field) is not None
]
if suspicious_fields:
raise SuspiciousOperation(
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
if permission.can_subscribe_metrics is not None:
raise serializers.ValidationError(
{
"permission": {
"can_subscribe_metrics": "This permission is not implemented."
}
}
)
return permission
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
@@ -300,12 +392,144 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
return attrs
class ListFileSerializer(serializers.ModelSerializer):
"""Serialize File model for the API."""
url = serializers.SerializerMethodField(read_only=True)
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.File
fields = [
"id",
"created_at",
"updated_at",
"title",
"type",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
"size",
"description",
"url",
"abilities",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
"size",
"url",
"abilities",
]
def get_url(self, obj):
"""Return the URL of the file."""
if obj.is_pending_upload:
return None
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
def get_abilities(self, file) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if not request:
return {}
return file.get_abilities(request.user)
class FileSerializer(ListFileSerializer):
"""Default serializer File model for the API."""
def create(self, validated_data):
raise NotImplementedError("Create method can not be used.")
class CreateFileSerializer(ListFileSerializer):
"""Serializer used to create a new file"""
title = serializers.CharField(max_length=255, required=False)
policy = serializers.SerializerMethodField()
class Meta:
model = models.File
fields = [*ListFileSerializer.Meta.fields, "policy"]
read_only_fields = [
*(
field
for field in ListFileSerializer.Meta.read_only_fields
if field != "filename"
),
"policy",
]
def get_fields(self):
"""Force the id field to be writable."""
fields = super().get_fields()
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new file."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and models.File.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A file with this ID already exists. You cannot override it.",
code="file_create_existing_id",
)
return value
def validate(self, attrs):
"""Validate extension and fill title."""
# we run the default validation first to make sure the base data in attrs is ok
attrs = super().validate(attrs)
filename_root, ext = splitext(attrs["filename"])
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]]
if ext.lower() not in config_for_file_type["allowed_extensions"]:
logger.info(
"create_item: file extension not allowed %s for filename %s",
ext,
attrs["filename"],
)
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
{"filename": _("This file extension is not allowed.")},
code="item_create_file_extension_not_allowed",
)
# The title will be the filename if not provided
if not attrs.get("title", None):
attrs["title"] = filename_root
return attrs
def get_policy(self, file):
"""Return the policy to use if the item is a file."""
if file.upload_state == models.FileUploadStateChoices.READY:
return None
return utils.generate_upload_policy(file)
def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.")
+75
View File
@@ -0,0 +1,75 @@
"""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
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the authenticated user ID as the throttle cache key."""
if request.user and not request.user.is_authenticated:
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
return super().get_cache_key(request, view)
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
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.
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.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
solely to guard against accidental hammering from buggy clients.
"""
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
if participant_id is None:
return None # No throttling for cookieless requests
return self.cache_format % {
"scope": self.scope,
"ident": participant_id,
}
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
+369 -35
View File
@@ -1,16 +1,26 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import uuid
from logging import getLogger
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from django_filters import rest_framework as django_filters
from rest_framework import (
decorators,
filters,
mixins,
pagination,
viewsets,
)
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -22,10 +32,13 @@ from rest_framework import (
)
from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
@@ -56,9 +69,10 @@ from core.services.participants_management import (
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -77,20 +91,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
def __getattribute__(self, file):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
if file in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1]
return super().__getattribute__(item)
return super().__getattribute__(file)
def get_queryset(self):
"""
Get the list of items for this view.
Get the list of files for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
@@ -191,18 +205,6 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -213,6 +215,7 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
pagination_class = Pagination
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -308,12 +311,14 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["options"]
options = serializer.validated_data.get("options")
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
@@ -379,7 +384,10 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
throttle_classes=[
throttling.RequestEntryAuthenticatedUserRateThrottle,
throttling.RequestEntryAnonRateThrottle,
],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -480,16 +488,14 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
return drf_response.Response({"status": "error"}, status=status_code)
@decorators.action(
detail=False,
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
@@ -619,13 +625,15 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
permission=permission.model_dump() if permission else None,
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
@@ -747,14 +755,19 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFileTypeError as e:
except InvalidFilepathError:
return drf_response.Response(
{"message": f"Ignore this file type, {e}"},
{"message": "Notification ignored."},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
)
try:
@@ -801,7 +814,7 @@ class RecordingViewSet(
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
@@ -818,7 +831,7 @@ class RecordingViewSet(
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
@@ -842,7 +855,7 @@ class RecordingViewSet(
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
if extension not in [file.value for file in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
@@ -866,3 +879,324 @@ class RecordingViewSet(
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
# pylint: disable=too-many-public-methods
class FileViewSet(
SerializerPerActionMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
FileViewSet API.
This viewset provides CRUD operations and additional actions for managing files.
### API Endpoints:
1. **List**: Retrieve a paginated list of files.
Example: GET /files/?page=2
2. **Retrieve**: Get a specific file by its ID.
Example: GET /files/{id}/
3. **Create**: Create a new file.
Example: POST /files/
4. **Update**: Update a file by its ID.
Example: PUT /files/{id}/
5. **Delete**: Soft delete a file by its ID.
Example: DELETE /files/{id}/
### Ordering: created_at, updated_at, title
Example:
- Ascending: GET /api/v1.0/files/?ordering=created_at
### Filtering:
- `is_creator_me=true`: Returns files created by the current user.
- `is_creator_me=false`: Returns files created by other users.
- `is_deleted=false`: Returns files that are not (soft) deleted
Example:
- GET /api/v1.0/files/?is_creator_me=true
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
### Notes:
- Implements soft delete logic to retain file
"""
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.FilePermission,
]
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
default_serializer_class = serializers.FileSerializer
serializer_classes = {
"list": serializers.ListFileSerializer,
"create": serializers.CreateFileSerializer,
}
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
filterset_class = ListFileFilter
def get_queryset(self):
"""Get queryset that defaults to the current request user."""
user = self.request.user
queryset = super().get_queryset().select_related("creator")
if not user.is_authenticated:
return queryset.none()
# For now, we force the filtering on the current user in all cases, might evolve later
queryset = queryset.filter(creator=user)
return queryset
def get_response_for_queryset(self, queryset, context=None):
"""Return paginated response for the queryset if requested."""
context = context or self.get_serializer_context()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True, context=context)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True, context=context)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator of the newly created file."""
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
file_type = serializer.validated_data["type"]
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
count = models.File.objects.filter(
creator=self.request.user,
deleted_at__isnull=True,
type=file_type,
).count()
if count >= config_for_file_type["max_count_by_user"]:
logger.info(
"create_item: user reached max files per user for type %s",
file_type,
)
raise serializers.PermissionDenied(
_("You have reached the maximum number of files for this type.")
)
serializer.save(creator=self.request.user)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@FeatureFlag.require("file_upload")
def upload_ended(self, request, *args, **kwargs):
"""
Check the actual uploaded file and mark it as ready.
"""
file = self.get_object()
if not file.is_pending_upload:
raise drf_exceptions.ValidationError(
{"file": "This action is only available for files in PENDING state."},
code="file_upload_state_not_pending",
)
s3_client = default_storage.connection.meta.client
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
self._complete_file_deletion(file)
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
# Use improved MIME type detection combining magic bytes and file extension
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
self._complete_file_deletion(file)
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
# Not yet implemented
# Change the file.upload_state when this will be done
# malware_detection.analyse_file(file.file_key, file_id=file.id)
serializer = self.get_serializer(file)
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
def _complete_file_deletion(self, file):
"""Delete a file completely."""
file.soft_delete()
file.hard_delete()
process_file_deletion.delay(file.id)
def _authorize_subrequest(self, request, pattern):
"""
Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(unquote(parsed_url.path))
if not match:
logger.warning(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf_exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
raise drf_exceptions.PermissionDenied()
# Fetch the file and check if the user has access
queryset = models.File.objects.all()
# No suspicious analysis implemented yet
# queryset = self._filter_suspicious_files(queryset, request.user)
try:
file = queryset.get(pk=pk)
except models.File.DoesNotExist as exc:
logger.warning("File with ID '%s' does not exist", pk)
raise drf_exceptions.PermissionDenied() from exc
user_abilities = file.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.warning(
"User '%s' lacks permission for file '%s'", request.user.id, pk
)
raise drf_exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, request.user.id, file
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
@FeatureFlag.require("file_upload")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to an file's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
url_params, _, file = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
if file.is_pending_upload:
logger.warning("File '%s' is not ready", file.id)
raise drf_exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
+7 -1
View File
@@ -14,9 +14,15 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
)
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
+146 -47
View File
@@ -1,25 +1,51 @@
"""Authentication Backends for external application to the Meet core app."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
from core.models import Application
from core.services import jwt_token
User = get_user_model()
logger = logging.getLogger(__name__)
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def __init__(
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
):
"""Initialize the JWT authentication backend with the given token service configuration.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (e.g. HS256)
issuer: Expected token issuer identifier
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
"""
super().__init__()
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
issuer=issuer,
audience=audience,
expiration_seconds=expiration_seconds,
token_type=token_type,
)
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -46,6 +72,78 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = self._token_service.decode_jwt(token)
return payload
except jwt_token.TokenExpiredError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt_token.TokenInvalidError as e:
logger.warning("Invalid JWT issuer or audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt_token.TokenDecodeError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -60,60 +158,61 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
payload = self.decode_jwt(token)
if payload is None:
return None
user_id = payload.get("user_id")
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
def validate_payload(self, payload):
"""Validate application-specific claims."""
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
application = Application.objects.get(client_id=client_id)
except Application.DoesNotExist as e:
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.is_active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
raise exceptions.AuthenticationFailed("Application is disabled.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return (user, payload)
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."""
+27 -5
View File
@@ -33,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create')
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
action = getattr(view, "action", None)
if not action:
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
# DRF routers return a 405 for unsupported methods
return True
required_scope = self.scope_map.get(action)
if not required_scope:
@@ -57,9 +56,12 @@ class BaseScopePermission(permissions.BasePermission):
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.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
for scope in token_scopes
]
@@ -82,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
user = request.user
if request.method in permissions.SAFE_METHODS:
return obj.has_any_role(user)
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
+30 -30
View File
@@ -1,14 +1,12 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
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.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -22,13 +20,14 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token import JwtTokenService
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.GenericViewSet):
class ApplicationViewSet(viewsets.ViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
@@ -62,12 +61,12 @@ class ApplicationViewSet(viewsets.GenericViewSet):
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
email = serializer.validated_data["scope"]
try:
validate_email(email)
@@ -93,7 +92,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email=email)
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,34 +122,33 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
token_service = JwtTokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
data = token_service.generate_jwt(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
)
return drf_response.Response(
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
data,
status=drf_status.HTTP_200_OK,
)
@@ -178,7 +176,9 @@ class RoomViewSet(
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
api.permissions.IsAuthenticated
& permissions.HasRequiredRoomScope
& permissions.RoomPermissions
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
+43 -1
View File
@@ -2,8 +2,11 @@
Core application factories
"""
from io import BytesIO
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.files.storage import default_storage
from django.utils.text import slugify
import factory.fuzzy
@@ -126,7 +129,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
model = models.Application
name = factory.Faker("company")
active = True
is_active = True
client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = []
@@ -153,3 +156,42 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
domain = factory.Faker("domain_name")
application = factory.SubFactory(ApplicationFactory)
class FileFactory(factory.django.DjangoModelFactory):
"""A factory to create files"""
class Meta:
model = models.File
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"file{n}")
creator = factory.SubFactory(UserFactory)
deleted_at = None
type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices])
filename = factory.lazy_attribute(lambda o: fake.file_name())
upload_state = None
size = None
@factory.post_generation
def update_upload_state(self, create, extracted, **kwargs):
"""Change the upload state of a file."""
if create and extracted:
self.upload_state = extracted
self.save()
@factory.post_generation
def upload_bytes(self, create, extracted, **kwargs):
"""Save content of the file into the storage"""
if create and extracted is not None:
content = (
extracted
if isinstance(extracted, bytes)
else str(extracted).encode("utf-8")
)
self.filename = kwargs.get("filename", self.filename or "content.txt")
self.size = len(content)
self.save()
default_storage.save(self.file_key, BytesIO(content))
+2 -2
View File
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -1,5 +1,5 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+42
View File
@@ -0,0 +1,42 @@
# Generated by Django 5.2.11 on 2026-03-03 15:22
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_recording_options'),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('type', models.CharField(choices=[('background_image', 'Background image')], max_length=25)),
('title', models.CharField(max_length=255, verbose_name='title')),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('hard_deleted_at', models.DateTimeField(blank=True, null=True)),
('filename', models.CharField(max_length=255)),
('upload_state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready')], max_length=25)),
('mimetype', models.CharField(blank=True, max_length=255, null=True)),
('size', models.BigIntegerField(blank=True, null=True)),
('description', models.TextField(blank=True, null=True)),
('malware_detection_info', models.JSONField(blank=True, default=dict, help_text='Malware detection info when the analysis status is unsafe.', null=True)),
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='files_created', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'File',
'verbose_name_plural': 'Files',
'db_table': 'file',
'ordering': ('created_at',),
'indexes': [models.Index(fields=['creator', 'type', '-created_at'], name='file_creator_730cce_idx')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-11 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0017_file'),
]
operations = [
migrations.RenameField(
model_name='application',
old_name='active',
new_name='is_active',
),
]
+192 -2
View File
@@ -1,11 +1,14 @@
"""
Declare and configure the models for the Meet core application
# pylint: disable=too-many-lines
"""
# pylint: disable=too-many-lines
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from os.path import splitext
from typing import List, Optional
from django.conf import settings
@@ -14,7 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.db import models, transaction
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -292,6 +295,10 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def has_any_role(self, user):
"""Check if a user has any role on the resource."""
return self.get_role(user) is not None
def is_administrator_or_owner(self, user):
"""
Check if a user is administrator or owner of the resource."""
@@ -752,7 +759,7 @@ class Application(BaseModel):
verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."),
)
active = models.BooleanField(default=True)
is_active = models.BooleanField(default=True)
client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id
)
@@ -825,3 +832,186 @@ class ApplicationDomain(BaseModel):
self.domain = self.domain.lower().strip()
super().save(*args, **kwargs)
class FileUploadStateChoices(models.TextChoices):
"""Possible states of a file."""
PENDING = "pending", _("Pending")
# Commented out for now, as we may need this when we implement the malware detection logic.
# ANALYZING = "analyzing", _("Analyzing")
# SUSPICIOUS = "suspicious", _("Suspicious")
# FILE_TOO_LARGE_TO_ANALYZE = (
# "file_too_large_to_analyze",
# _("File too large to analyze"),
# )
READY = "ready", _("Ready")
class FileTypeChoices(models.TextChoices):
"""Defines the possible types of a file."""
BACKGROUND_IMAGE = "background_image", _("Background image")
class File(BaseModel):
"""File uploaded by a user."""
type = models.CharField(
max_length=25,
choices=FileTypeChoices.choices,
null=False,
blank=False,
)
title = models.CharField(_("title"), max_length=255)
creator = models.ForeignKey(
User,
on_delete=models.RESTRICT,
related_name="files_created",
blank=True,
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
hard_deleted_at = models.DateTimeField(null=True, blank=True)
filename = models.CharField(max_length=255, null=False, blank=False)
upload_state = models.CharField(
max_length=25,
choices=FileUploadStateChoices.choices,
)
mimetype = models.CharField(max_length=255, null=True, blank=True)
size = models.BigIntegerField(null=True, blank=True)
description = models.TextField(null=True, blank=True)
malware_detection_info = models.JSONField(
null=True,
blank=True,
default=dict,
help_text=_("Malware detection info when the analysis status is unsafe."),
)
class Meta:
db_table = "file"
verbose_name = _("File")
verbose_name_plural = _("Files")
ordering = ("created_at",)
indexes = [
models.Index(fields=["creator", "type", "-created_at"]),
]
def __str__(self):
return str(self.title)
def save(self, *args, **kwargs):
"""Set the upload state to pending if it's the first save and it's a file."""
if self.created_at is None:
self.upload_state = FileUploadStateChoices.PENDING
return super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False):
if self.deleted_at is None:
raise RuntimeError("The file must be soft deleted before being deleted.")
return super().delete(using, keep_parents)
@property
def is_pending_upload(self):
"""Return whether the file is in a pending upload state"""
return self.upload_state == FileUploadStateChoices.PENDING
@property
def extension(self):
"""Return the extension related to the filename."""
if self.filename is None:
raise RuntimeError(
"The file must have a filename to compute its extension."
)
_, extension = splitext(self.filename)
if extension:
return extension.lstrip(".")
return None
@property
def key_base(self):
"""Key base of the location where the file is stored in object storage."""
if not self.pk:
raise RuntimeError(
"The file instance must be saved before requesting a storage key."
)
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
@property
def file_key(self):
"""Key used to store the file in object storage."""
_, extension = splitext(self.filename)
# We store only the extension in the storage system to avoid
# leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the file.
"""
# Characteristics that are based only on specific access
is_creator = user == self.creator
retrieve = is_creator
is_deleted = self.deleted_at is not None
can_update = is_creator and not is_deleted and user.is_authenticated
can_hard_delete = is_creator and user.is_authenticated
can_destroy = can_hard_delete and not is_deleted
return {
"destroy": can_destroy,
"hard_delete": can_hard_delete,
"retrieve": retrieve,
"media_auth": retrieve and not is_deleted,
"partial_update": can_update,
"update": can_update,
"upload_ended": can_update and user.is_authenticated,
}
@transaction.atomic
def soft_delete(self):
"""
Soft delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at:
raise RuntimeError("This file is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def hard_delete(self):
"""
Hard delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.hard_deleted_at:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("This file is already hard deleted."),
code="file_hard_delete_already_effective",
)
}
)
if self.deleted_at is None:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("To hard delete a file, it must first be soft deleted."),
code="file_hard_delete_should_soft_delete_first",
)
}
)
self.hard_deleted_at = timezone.now()
self.save(update_fields=["hard_deleted_at"])
@@ -167,6 +167,7 @@ class NotificationService:
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
headers = {
+3 -1
View File
@@ -9,6 +9,8 @@ from typing import Any, Dict, Optional, Protocol
from django.conf import settings
from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -86,7 +88,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
@staticmethod
@@ -16,7 +16,7 @@ class RecordingEventsError(Exception):
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
+153
View File
@@ -0,0 +1,153 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class JWTError(Exception):
"""Base exception for all JWT token errors."""
class TokenExpiredError(JWTError):
"""Raised when the JWT token has expired."""
class TokenInvalidError(JWTError):
"""Raised when the JWT token has an invalid issuer or audience."""
class TokenDecodeError(JWTError):
"""Raised for any other unrecoverable JWT decode failure."""
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
if not algorithm:
raise ImproperlyConfigured("Algorithm is required.")
if not token_type:
raise ImproperlyConfigured("Token's type is required.")
if expiration_seconds is None:
raise ImproperlyConfigured("Expiration's seconds is required.")
self._key = secret_key
self._algorithm = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_jwt(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Note: any extra_payload variables named iat, exp, or user_id will
be overwritten by this service
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope optionally
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._algorithm,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict.
Raises:
TokenExpiredError: If the token has expired.
TokenInvalidError: If the token has an invalid issuer or audience.
TokenDecodeError: If the token is malformed or cannot be decoded.
"""
try:
payload = jwt.decode(
token,
self._key,
algorithms=[self._algorithm],
issuer=self._issuer,
audience=self._audience,
)
return payload
except jwt.ExpiredSignatureError as e:
raise TokenExpiredError("Token expired.") from e
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
raise TokenInvalidError("Invalid token.") from e
except jwt.InvalidTokenError as e:
raise TokenDecodeError("Token decode error.") from e
+1 -1
View File
@@ -129,7 +129,7 @@ class LobbyService:
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
This usual status transitions is:
The usual status transitions are:
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
Flow:
+49
View File
@@ -0,0 +1,49 @@
# ruff: noqa: PLC0415
from django.conf import settings
def task(*d_args, **d_kwargs):
"""
Decorator compatible with Celery's @app.task, but works without Celery.
If Celery is available, returns a real Celery task.
If not, returns the original function and provides `.delay()`/`.apply_async()`
as synchronous fallbacks (so existing call sites don't break).
Notes:
Mostly LLM-generated.
"""
def _fallback_wrap(func):
def delay(*args, **kwargs):
return func(*args, **kwargs)
def apply_async(args=None, kwargs=None, **_options):
return func(*(args or ()), **(kwargs or {}))
func.delay = delay
func.apply_async = apply_async
return func
# Handle bare decorator usage: @task
if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs:
func = d_args[0]
if settings.CELERY_ENABLED:
from meet.celery_app import app as _celery_app
return _celery_app.task(func)
return _fallback_wrap(func)
# Handle parameterized usage: @task(...), e.g. @task(bind=True)
def _decorate(func):
if settings.CELERY_ENABLED:
from meet.celery_app import app as _celery_app
return _celery_app.task(*d_args, **d_kwargs)(func)
return _fallback_wrap(func)
return _decorate
__all__ = ("task",)
+36
View File
@@ -0,0 +1,36 @@
"""
Tasks related to files.
"""
import logging
from django.core.files.storage import default_storage
from core.models import File
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def process_file_deletion(file_id):
"""
Process the deletion of a file.
Definitely delete it in the database.
Delete the files from the storage.
"""
logger.info("Processing item deletion for %s", file_id)
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
logger.error("Item %s does not exist", file_id)
return
if file.hard_deleted_at is None:
logger.error("To process an item deletion, it must be hard deleted first.")
return
logger.info("Deleting file %s", file.file_key)
default_storage.delete(file.file_key)
file.delete()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Custom template tags for the core application of People."""
"""Custom template tags for the core application of Meet."""
import base64
@@ -0,0 +1,385 @@
"""
Tests for files API endpoint in meet's core app: create
"""
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
from django.utils import timezone
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
from core.models import File, FileTypeChoices, FileUploadStateChoices
pytestmark = pytest.mark.django_db
def test_api_files_create_anonymous():
"""Anonymous users should not be allowed to create items."""
response = APIClient().post(
"/api/v1.0/files/",
{
"title": "My file",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not File.objects.exists()
def test_api_files_create_authenticated_success():
"""
Authenticated users should be able to create files and should automatically be declared
as the owner of the newly created file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"title": "my file",
"filename": "my_file.png",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "my file"
assert file.creator == user
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
assert file.upload_state == FileUploadStateChoices.PENDING
def test_api_files_create_file_authenticated_no_filename():
"""
Creating a file item without providing a filename should fail.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"title": "my item",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This field is required."]}
def test_api_files_create_file_authenticated_success():
"""
Authenticated users should be able to create a file file and must provide a filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now()
with freeze_time(now):
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"title": "Eiffle tower",
"filename": "file.png",
},
format="json",
)
assert response.status_code == 201
file = File.objects.get()
assert file.title == "Eiffle tower"
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
assert file.filename == "file.png"
response_data = response.json()
assert response_data["creator"] is not None, response_data
assert response.json().get("policy") is not None
policy = response.json()["policy"]
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
assert query_params.pop("X-Amz-Credential") == [
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
]
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
assert query_params.pop("X-Amz-Expires") == ["60"]
assert query_params.pop("X-Amz-SignedHeaders") == ["host;x-amz-acl"]
assert query_params.pop("X-Amz-Signature") is not None
assert len(query_params) == 0
def test_api_files_create_file_authenticated_extension_not_allowed():
"""
Creating a file item with an extension not allowed should fail.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"title": "Paris tower",
"filename": "file.notallowed",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_authenticated_extension_case_insensitive():
"""
Creating a file item with an extension, no matter the case used, should be allowed.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "file"
def test_api_files_create_file_disabled(settings):
"""
Creating a file is denied if file upload is disabled
"""
settings.FILE_UPLOAD_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 404
assert not File.objects.exists()
def test_api_files_create_file_authenticated_not_checking_extension(settings):
"""
Creating a file with an extension not allowed should not fail when restrictions are disabled.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.notallowed",
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "file"
def test_api_files_create_file_authenticated_no_extension_but_checking_it_should_fail(
settings,
):
"""
Creating a file without an extension but checking the extension should fail.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_should_fail(
settings,
):
"""
Creating a hidden file (starting with a dot) but checking the extension should fail.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": ".file",
},
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_too_many(
settings,
):
"""
Creating a file is forbidden if above user limit.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_count_by_user": 1,
},
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "1.png",
},
)
assert response.status_code == 201
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "2.png",
},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You have reached the maximum number of files for this type."
}
assert File.objects.count() == 1
def test_api_files_create_force_id_success():
"""It should be possible to force the item ID when creating a item."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
forced_id = uuid4()
response = client.post(
"/api/v1.0/files/",
{
"id": str(forced_id),
"title": "my item",
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
assert response.status_code == 201, response.json()
files = File.objects.all()
assert len(files) == 1
assert files[0].id == forced_id
def test_api_files_create_force_id_existing():
"""
It should not be possible to use the ID of an existing file when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory()
response = client.post(
"/api/v1.0/files/",
{
"id": str(file.id),
"title": "my file",
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A file with this ID already exists. You cannot override it."]
}
@pytest.mark.django_db(transaction=True)
def test_api_files_create_file_race_condition():
"""
It should be possible to create several files at the same time
without causing any race conditions or data integrity issues.
"""
def create_item(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_item, "my item 1")
future2 = executor.submit(create_item, "my item 2")
response1 = future1.result()
response2 = future2.result()
assert response1.status_code == 201
assert response2.status_code == 201
@@ -0,0 +1,45 @@
"""
Tests for files API endpoint in meet's core app: delete
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_delete_anonymous():
"""Anonymous users should not be allowed to destroy a file."""
file = factories.FileFactory()
existing_items = models.File.objects.all().count()
response = APIClient().delete(
f"/api/v1.0/files/{file.id!s}/",
)
assert response.status_code == 401
assert models.File.objects.count() == existing_items
def test_api_files_delete_authenticated_owner():
"""
Authenticated users should be able to delete a item they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(creator=user)
response = client.delete(
f"/api/v1.0/files/{file.id}/",
)
assert response.status_code == 204
# Make sure it is only a soft delete
file.refresh_from_db()
assert file.deleted_at is not None
@@ -0,0 +1,160 @@
"""
Tests for files API endpoint in meet's core app: list
"""
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
def test_api_files_list_anonymous_not_allowed():
"""
Anonymous users should not be allowed to list files whatever the
"""
response = APIClient().get("/api/v1.0/files/")
assert response.status_code == 401
def test_api_files_list_authentificated_user_allowed():
"""
Authentificated users should be allowed to list files
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
assert response.data == {"count": 0, "next": None, "previous": None, "results": []}
def test_api_files_list_format():
"""Validate the format of files as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
title="item 1",
creator=user,
)
# A file from another user should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
title="item 2",
)
# hard deleted item should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
hard_deleted_at=timezone.now(),
title="hard deleted item",
creator=user,
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results == [
{
"id": str(file.id),
"created_at": file.created_at.isoformat().replace("+00:00", "Z"),
"creator": {
"id": str(file.creator.id),
"full_name": file.creator.full_name,
"short_name": file.creator.short_name,
},
"title": file.title,
"updated_at": file.updated_at.isoformat().replace("+00:00", "Z"),
"type": models.FileTypeChoices.BACKGROUND_IMAGE,
"upload_state": file.upload_state,
"url": None,
"mimetype": file.mimetype,
"filename": file.filename,
"size": None,
"description": None,
"deleted_at": None,
"hard_deleted_at": None,
"abilities": {
"destroy": True,
"hard_delete": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
"upload_ended": True,
},
}
]
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_files_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file_ids = [
str(file.id)
for file in factories.FileFactory.create_batch(
3,
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
)
]
# Get page 1
response = client.get(
"/api/v1.0/files/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/files/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
file_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/files/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/files/"
assert len(content["results"]) == 1
for item in content["results"]:
file_ids.remove(item["id"])
assert file_ids == []
@@ -0,0 +1,216 @@
"""
Tests for files API endpoint in meet's core app: list
"""
import pytest
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
# Filters: unknown field
def test_api_files_list_filter_unknown_field():
"""
Trying to filter by an unknown field should do nothing.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory(type=models.FileTypeChoices.BACKGROUND_IMAGE)
expected_ids = {
str(file.id)
for file in factories.FileFactory.create_batch(
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
}
response = client.get("/api/v1.0/files/?unknown=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert {result["id"] for result in results} == expected_ids
# Filters: is_creator_me
def test_api_files_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter files they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
factories.FileFactory.create_batch(2, type=models.FileTypeChoices.BACKGROUND_IMAGE)
response = client.get("/api/v1.0/files/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == {
"id": str(user.id),
"full_name": user.full_name,
"short_name": user.short_name,
}
def test_api_files_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
3, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
response = client.get("/api/v1.0/files/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Filters: type
def test_api_files_list_filter_type_and_upload_status():
"""
Authenticated users should be able to filter files by their type and upload status.
This test will make more sense when other types are added to the API
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
assert file.upload_state == models.FileUploadStateChoices.PENDING
expected_files = factories.FileFactory.create_batch(
2,
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
expected_files_ids = {str(file.id) for file in expected_files}
# Filter by type: background_image & upload state
response = client.get("/api/v1.0/files/?type=background_image&upload_state=ready")
assert response.status_code == 200
assert response.json()["count"] == 2
results = response.json()["results"]
# Ensure all results are background images
results_ids = {result["id"] for result in results}
assert results_ids == expected_files_ids
for result in results:
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
assert result["upload_state"] == models.FileUploadStateChoices.READY
# Second request without the upload_state filter, to check that all 3 show up
response = client.get("/api/v1.0/files/?type=background_image")
assert response.status_code == 200
assert response.json()["count"] == 3
results = response.json()["results"]
# Ensure all results are background images
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in expected_files + [file]}
for result in results:
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
def test_api_files_list_filter_is_deleted():
"""
Authenticated users should be able to filter files by their deletion status.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
not_deleted_file = factories.FileFactory(creator=user)
deleted_files = factories.FileFactory.create_batch(2, creator=user)
for file in deleted_files:
file.soft_delete()
# No filters
response_no_filters = client.get("/api/v1.0/files/")
assert response_no_filters.status_code == 200
assert response_no_filters.json()["count"] == 3
results = response_no_filters.json()["results"]
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in [*deleted_files, not_deleted_file]}
# Filters deleted
response_filter_deleted = client.get("/api/v1.0/files/?is_deleted=true")
assert response_filter_deleted.status_code == 200
assert response_filter_deleted.json()["count"] == 2
results = response_filter_deleted.json()["results"]
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in deleted_files}
# Filters not deleted
response_filter_not_deleted = client.get("/api/v1.0/files/?is_deleted=false")
assert response_filter_not_deleted.status_code == 200
assert response_filter_not_deleted.json()["count"] == 1
results = response_filter_not_deleted.json()["results"]
# Ensure all results are deleted
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in [not_deleted_file]}
def test_api_files_list_filter_unknown_type():
"""
Filtering by an unknown type should return an empty list
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(3, creator=user)
response = client.get("/api/v1.0/files/?type=unknown")
assert response.status_code == 400
assert response.json() == {
"type": ["Select a valid choice. unknown is not one of the available choices."]
}
@@ -0,0 +1,71 @@
"""Test the ordering of items."""
import operator
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_list_ordering_default():
"""items should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Check that results are sorted by descending "updated_at" as expected
for i in range(3):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_files_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/files/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(3):
operator1 = (
results[i][field].lower()
if isinstance(results[i][field], str)
else results[i][field]
)
operator2 = (
results[i + 1][field].lower()
if isinstance(results[i + 1][field], str)
else results[i + 1][field]
)
assert compare(operator1, operator2)
@@ -0,0 +1,141 @@
"""
Test file uploads API endpoint for users in meet's core app.
"""
from io import BytesIO
from urllib.parse import quote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_media_auth_anonymous_not_authorized():
"""Anonymous users should not be allowed to retrieve a file"""
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
original_url = f"http://localhost/media/{file.file_key:s}"
response = APIClient().get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_files_media_get_own():
"""
Authenticated user should be allowed to retrieve their own file.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
original_url = f"http://localhost/media/{file.file_key:s}"
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{file.file_key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_files_media_auth_file_pending():
"""
Users who have a specific access to an file, whatever the role, should not be able to
retrieve related attachments if the file is not ready.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_state=models.FileUploadStateChoices.PENDING,
creator=user,
)
key = file.file_key
original_url = quote(f"http://localhost/media/{key:s}")
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_files_media_auth_own_file_deleted():
"""
This function tests the access restrictions on deleted files through the media
authorization API endpoint. It ensures that a user cannot retrieve a file that is deleted.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
file.soft_delete()
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -0,0 +1,67 @@
"""
Tests for files API endpoint in meet's core app: update
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_files_update_anonymous_forbidden():
"""
Anonymous users should not be allowed to update an file when link
configuration does not allow it.
"""
file = factories.FileFactory()
old_file_values = serializers.FileSerializer(instance=file).data
new_file_values = serializers.FileSerializer(instance=factories.FileFactory()).data
response = APIClient().put(
f"/api/v1.0/files/{file.id!s}/",
new_file_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
file.refresh_from_db()
item_values = serializers.FileSerializer(instance=file).data
assert item_values == old_file_values
def test_api_files_update_description_and_title():
"""
Test the description and title of a file can be updated.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
description="Old description",
title="Old title",
creator=user,
)
response = client.patch(
f"/api/v1.0/files/{file.id!s}/",
{"description": "New description", "title": "New title"},
format="json",
)
assert response.status_code == 200
result = response.json()
assert result["description"] == "New description"
assert result["title"] == "New title"
file.refresh_from_db()
assert file.description == "New description"
assert file.title == "New title"
@@ -0,0 +1,272 @@
"""Test related to item upload ended API."""
import logging
from io import BytesIO
from django.core.files.storage import default_storage
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.models import FileTypeChoices, FileUploadStateChoices
pytestmark = pytest.mark.django_db
def test_api_file_upload_ended_anonymous():
"""Anonymous users should not be allowed to end an upload."""
file = factories.FileFactory()
response = APIClient().post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 401
def test_api_file_upload_ended_non_creator_not_found():
"""Users without write permissions should not be allowed to end an upload."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory()
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 404
def test_api_file_upload_ended_on_wrong_upload_state():
"""
Users should not be allowed to end an upload on files that are not in the PENDING upload state.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
creator=user,
update_upload_state=FileUploadStateChoices.READY,
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 400
assert response.json() == {
"file": "This action is only available for files in PENDING state."
}
def test_api_file_upload_ended_success(settings):
"""
Users should be able to end an upload on files that are files and in the UPLOADING upload state.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/html", "text/plain"],
},
}
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.txt",
mimetype="text/html",
creator=user,
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 200
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
assert file.mimetype == "text/plain"
assert file.size == 8
assert response.json()["mimetype"] == "text/plain"
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
"""
Test that the API returns a 400 when the mimetype is not allowed.
File should be deleted and the file should be deleted from the storage.
"""
settings.RESTRICT_UPLOAD_FILE_TYPE = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
with caplog.at_level(logging.WARNING):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 400
assert (
f"upload_ended: mimetype not allowed text/plain for file {file.file_key}"
in caplog.text
)
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
"""
Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 200
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
assert file.mimetype == "text/plain"
assert file.size == 8
assert response.json()["mimetype"] == "text/plain"
def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog):
"""
Object on storage should have the same mimetype than the one saved in the
File object.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/html", "application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.pdf",
title="my_file.pdf",
creator=user,
)
s3_client = default_storage.connection.meta.client
s3_client.put_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
ContentType="text/html",
Body=BytesIO(
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
),
Metadata={
"foo": "bar",
},
)
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "text/html"
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert (
"upload_ended: content type mismatch between object storage and file,"
" updating from text/html to application/pdf" in caplog.text
)
assert response.status_code == 200
file.refresh_from_db()
assert file.mimetype == "application/pdf"
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "application/pdf"
assert head_object["Metadata"] == {"foo": "bar"}
def test_api_upload_ended_file_size_exceeded(settings, caplog):
"""
Test when the file size exceed the allowed max upload file size
should return a 400 and delete the file.
"""
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_size": 0,
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert (
f"upload_ended: file size (8) for file {file.file_key} higher than the allowed max size"
in caplog.text
)
assert response.status_code == 400
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
@@ -102,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_DOWNLOAD_BASE_URL = None
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
@@ -32,7 +32,7 @@ def valid_minio_event():
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
@@ -130,11 +130,13 @@ def test_validate_invalid_filetype(minio_parser):
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
],
)
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
@@ -152,7 +154,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
def test_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
@@ -170,7 +172,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
@@ -219,7 +221,7 @@ def test_validate_custom_filetypes():
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
@@ -14,6 +14,7 @@ from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
@@ -94,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data: Error message"}
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -111,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified"}
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -132,7 +133,28 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
)
assert response.status_code == 200
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
@@ -120,3 +120,39 @@ def test_api_rooms_list_authenticated_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(room.id)
def test_api_rooms_list_pagination_page_size():
"""Users should be able to customize the number of results per page via page_size param."""
user = UserFactory()
client = APIClient()
client.force_login(user)
RoomFactory.create_batch(11, users=[user])
response = client.get("/api/v1.0/rooms/?page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 2
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=2"
assert content["previous"] is None
response = client.get("/api/v1.0/rooms/?page=6&page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 1
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/rooms/?page=5&page_size=2"
response = client.get("/api/v1.0/rooms/?page_size=3")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None
@@ -631,3 +631,109 @@ def test_list_waiting_participants_empty(settings):
assert response.status_code == 200
assert response.json() == {"participants": []}
@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_throttling_anonymous_without_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie 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(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
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/",
{"username": "test_user"},
)
assert response.status_code == 200
@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_throttling_anonymous_with_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie 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})
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@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_throttling_authenticated_user(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Authenticated users should be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
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(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@@ -8,6 +8,7 @@ import random
from unittest import mock
from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
@@ -132,11 +133,7 @@ def test_update_participant_success(mock_livekit_client):
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
@@ -151,6 +148,151 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"permission_payload",
[
{}, # empty dict is valid
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{"can_publish_sources": [1, 2]},
{"can_update_metadata": True},
],
)
def test_update_participant_permission_fields_are_optional(
mock_livekit_client, permission_payload
):
"""Test that each required permission field can be passed individually."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": permission_payload,
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"value,permission_key",
[
(False, "hidden"),
(True, "hidden"),
(False, "recorder"),
(True, "recorder"),
(False, "agent"),
(True, "agent"),
],
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission(
mock_suspicious, value, permission_key
):
"""Test update participant raises 400 when a restricted permission is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
permission_key: value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
f"Setting the following participant permissions is not allowed: {permission_key}."
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
"""Test update participant raises 400 when multiple suspicious permissions are set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"hidden": True,
"recorder": False,
"can_update_metadata": False,
"agent": True,
"can_subscribe_metrics": False,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
)
@pytest.mark.parametrize("value", (False, True))
def test_update_participant_unimplemented_can_subscribe_metrics(value):
"""Test update participant raises 400 when can_subscribe_metrics is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
"can_subscribe_metrics": value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "can_subscribe_metrics" in str(response.data)
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
@@ -226,7 +368,17 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Invalid permission" in str(response.data)
assert response.json() == {
"permission": [
{
"type": "extra_forbidden",
"loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted",
"input": "True",
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
},
]
}
def test_update_participant_wrong_metadata_attributes():
@@ -199,3 +199,308 @@ def test_start_recording_success(
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
def test_start_recording_options_language_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept a valid ISO 639-1 language code."""
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": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": value}
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
def test_start_recording_options_language_not_validated(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Invalid language codes are currently accepted — no format validation yet.
TODO: tighten this once language validation is introduced.
"""
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": value}},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_language_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept null language (triggers auto-detection)."""
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": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
def test_start_recording_options_transcribe_valid_true(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic true values."""
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": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": True}
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
def test_start_recording_options_transcribe_valid_false(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic false values."""
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": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": False}
def test_start_recording_options_transcribe_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept transcribe=null (falls back to application default)."""
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": {"transcribe": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept options=null."""
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": None},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with no options field at all."""
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"},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_unknown_field_rejected(settings):
"""Should reject unknown fields in options (extra='forbid')."""
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": {"unknown_field": "value"}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
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": {"transcribe": value}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
def test_start_recording_options_original_mode_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept valid recording mode choices for original_mode."""
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": {"original_mode": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"original_mode": value}
def test_start_recording_options_original_mode_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept original_mode=null."""
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": {"original_mode": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_original_mode_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with original_mode omitted."""
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": {}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
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": {"original_mode": value}},
format="json",
)
assert response.status_code == 400
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
)
assert response.status_code == 400
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
assert response.json() == {"status": "error"}
def test_unknown_event_type(client, mock_livekit_config):
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
+617 -37
View File
@@ -2,21 +2,21 @@
Tests for external API /room endpoint
"""
# pylint: disable=W0621
# pylint: disable=W0621,C0302
import uuid
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings
import jwt
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -27,12 +27,14 @@ def generate_test_token(user, scopes):
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": "test-client-id",
"client_id": str(application.client_id),
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
@@ -53,10 +55,24 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
def test_api_rooms_list_inactive_user():
"""List should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token():
"""Listing rooms with valid token should succeed."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -73,9 +89,25 @@ def test_api_rooms_list_with_valid_token(settings):
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms():
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
user = UserFactory()
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 0
assert response.data["results"] == []
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -92,8 +124,8 @@ def test_api_rooms_list_with_expired_token(settings):
@responses.activate
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
def test_api_rooms_list_with_invalid_rs_token(settings):
"""Listing rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -116,9 +148,8 @@ def test_api_rooms_list_with_invalid_token(settings):
assert response.status_code == 400
def test_api_rooms_list_missing_scope(settings):
def test_api_rooms_list_missing_scope():
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -130,12 +161,30 @@ def test_api_rooms_list_missing_scope(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
assert (
"insufficient permissions. required scope: rooms:list"
in str(response.data).lower()
)
def test_api_rooms_list_filters_by_user(settings):
def test_api_rooms_list_no_scope():
"""Listing rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_list_filters_by_user():
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
@@ -144,7 +193,9 @@ def test_api_rooms_list_filters_by_user(settings):
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -158,9 +209,82 @@ def test_api_rooms_list_filters_by_user(settings):
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_scope(settings):
def test_api_rooms_retrieve_requires_authentication():
"""Retrieving rooms without authentication should return 401."""
user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
client = APIClient()
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user():
"""Retrieve should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
"""Retrieving rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_retrieve_requires_scope():
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -178,9 +302,25 @@ def test_api_rooms_retrieve_requires_scope(settings):
)
def test_api_rooms_retrieve_no_scope():
"""Retrieving rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
@@ -212,9 +352,128 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_create_requires_scope(settings):
def test_api_rooms_retrieve_success_by_user():
"""Retrieve should only return rooms accessible to the authenticated user."""
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
assert response.status_code == 403
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
assert response.status_code == 200
def test_api_rooms_retrieve_not_found():
"""Retrieving a non-existing room with correct scope should return a 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication():
"""Creating rooms without authentication should return 401."""
client = APIClient()
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_create_with_invalid_rs_token(settings):
"""Creating rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.post("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_create_inactive_user():
"""Create should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope():
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
@@ -225,18 +484,36 @@ def test_api_rooms_create_requires_scope(settings):
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
assert (
"insufficient permissions. required scope: rooms:create"
in str(response.data).lower()
)
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
def test_api_rooms_create_no_scope():
"""Creating rooms without any scope should return 403."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success():
"""Creating a room with correct scope should succeed."""
user = UserFactory()
token = generate_test_token(
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -245,6 +522,8 @@ def test_api_rooms_create_success(settings):
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
assert "name" in response.data
assert response.data["name"] == response.data["slug"]
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
@@ -252,9 +531,72 @@ def test_api_rooms_create_success(settings):
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement():
"""Creating a room succeeds and any provided read-only fields are ignored."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{
"id": "fake-id",
"slug": "fake-slug",
"name": "fake-name",
"access_level": "public",
},
format="json",
)
assert response.status_code == 201
assert "slug" in response.data
assert response.data["id"] != "fake-id"
assert "name" in response.data
assert response.data["slug"] != "fake-slug"
assert "id" in response.data
assert response.data["name"] != "fake-name"
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions():
"""Updating or deleting a room are not supported yet."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(
user,
[
ApplicationScope.ROOMS_RETRIEVE,
ApplicationScope.ROOMS_DELETE,
ApplicationScope.ROOMS_UPDATE,
],
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "delete" not allowed.' in str(response.data).lower()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "patch" not allowed.' in str(response.data).lower()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
@@ -273,7 +615,6 @@ def test_api_rooms_response_no_url(settings):
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
@@ -290,10 +631,41 @@ def test_api_rooms_response_no_telephony(settings):
assert response.data["id"] == str(room.id)
def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive."""
user = UserFactory()
application = ApplicationFactory()
# Generate token with mixed-case scope "Rooms:List" to verify that scope
# validation is case-insensitive (should match "rooms:list")
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
@@ -302,7 +674,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
@@ -318,12 +690,75 @@ def test_api_rooms_token_without_delegated_flag(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
assert "invalid token type." in str(response.data).lower()
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication."""
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
"invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm="HS256", # different value
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
@@ -348,7 +783,152 @@ def test_api_rooms_token_missing_client_id(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected."""
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"delegated": True,
# Missing user_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected."""
user = UserFactory()
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": "invalid-audience",
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(user.id),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token." in str(response.data).lower()
def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected."""
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected."""
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "unknown-client-id",
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application not found." in str(response.data).lower()
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
application = ApplicationFactory(is_active=False)
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application is disabled." in str(response.data).lower()
@responses.activate
@@ -507,7 +1087,7 @@ def test_resource_server_authentication_successful(settings):
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
"active": True,
},
)
@@ -21,10 +21,9 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -40,7 +39,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
"scope": "user.family@example.com",
},
format="json",
)
@@ -80,7 +79,7 @@ def test_api_applications_generate_token_invalid_client_id():
def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
@@ -101,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=False)
application = ApplicationFactory(is_active=False)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -123,9 +122,31 @@ def test_api_applications_generate_token_inactive_application():
assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_inactive_application_wrong_secret():
"""An inactive application with a wrong secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
assert "inactive" not in str(response.data).lower()
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -150,7 +171,7 @@ def test_api_applications_generate_token_invalid_email_format():
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
@@ -173,12 +194,11 @@ def test_api_applications_generate_token_domain_not_authorized():
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized(settings):
def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
@@ -205,7 +225,7 @@ def test_api_applications_generate_token_domain_authorized(settings):
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -230,11 +250,10 @@ def test_api_applications_generate_token_user_not_found():
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -280,7 +299,6 @@ def test_api_applications_token_payload_structure(settings):
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
@@ -288,7 +306,7 @@ def test_api_applications_token_new_user(settings):
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -337,7 +355,6 @@ def test_api_applications_token_new_user(settings):
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
@@ -347,7 +364,7 @@ def test_api_applications_token_existing_user(settings):
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
def test_models_application_active_default():
"""An application should be active by default."""
application = Application.objects.create(name="Test App")
assert application.active is True
assert application.is_active is True
def test_models_application_scopes_default():
@@ -0,0 +1,166 @@
"""
Test utils.detect_mimetype
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
"""
import pytest
from core import utils
def test_detect_mimetype_from_content_pdf():
"""Test detect_mimetype detects PDF from content (magic bytes)."""
# PDF magic bytes: %PDF
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename="document.pdf")
assert mimetype == "application/pdf"
def test_detect_mimetype_from_content_png():
"""Test detect_mimetype detects PNG from content (magic bytes)."""
# PNG magic bytes: \x89PNG\r\n\x1a\n
png_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
mimetype = utils.detect_mimetype(png_content, filename="image.png")
assert mimetype == "image/png"
def test_detect_mimetype_from_content_jpeg():
"""Test detect_mimetype detects JPEG from content (magic bytes)."""
# JPEG magic bytes: \xff\xd8\xff
jpeg_content = b"\xff\xd8\xff\xe0\x00\x10JFIF"
mimetype = utils.detect_mimetype(jpeg_content, filename="photo.jpg")
assert mimetype == "image/jpeg"
def test_detect_mimetype_from_content_text_plain():
"""Test detect_mimetype detects plain text from content."""
text_content = b"This is plain text content"
mimetype = utils.detect_mimetype(text_content, filename="file.txt")
assert mimetype == "text/plain"
def test_detect_mimetype_empty_file():
"""Test detect_mimetype handles empty files."""
empty_content = b""
mimetype = utils.detect_mimetype(empty_content, filename="empty.txt")
# Empty files should be detected as application/x-empty by magic bytes
assert mimetype == "application/x-empty"
def test_detect_mimetype_uses_extension_when_content_generic():
"""Test detect_mimetype uses extension when content detection returns generic type."""
# Generic binary content that might be detected as application/octet-stream
generic_content = b"\x00\x01\x02\x03\x04\x05"
# But if we have a specific extension, we should use it
mimetype = utils.detect_mimetype(generic_content, filename="document.pdf")
# Should prefer extension-based detection for specific types
assert mimetype == "application/pdf"
def test_detect_mimetype_uses_extension_for_json():
"""Test detect_mimetype uses extension for JSON files."""
# JSON content might be detected as text/plain
json_content = b'{"key": "value"}'
mimetype = utils.detect_mimetype(json_content, filename="data.json")
# Should use extension to get application/json
assert mimetype == "application/json"
def test_detect_mimetype_without_filename():
"""Test detect_mimetype works without filename (content-only detection)."""
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename=None)
assert mimetype == "application/pdf"
def test_detect_mimetype_fallback_to_extension():
"""Test detect_mimetype falls back to extension when content detection is generic."""
# Content that might be detected as text/plain
content = b"some content"
mimetype = utils.detect_mimetype(content, filename="script.js")
# Should use extension to get JavaScript MIME type
# (can be text/javascript or application/javascript)
assert mimetype in ["text/javascript", "application/javascript"]
def test_detect_mimetype_generic_content_no_extension():
"""Test detect_mimetype with generic content and no extension."""
generic_content = b"\x00\x01\x02\x03"
mimetype = utils.detect_mimetype(generic_content, filename="file")
# Should return content-based detection (likely application/octet-stream)
assert mimetype in ["application/octet-stream", "application/x-empty"]
def test_detect_mimetype_xml_file():
"""Test detect_mimetype detects XML files."""
xml_content = b'<?xml version="1.0"?><root></root>'
mimetype = utils.detect_mimetype(xml_content, filename="data.xml")
# Should detect as XML (either from content or extension)
assert mimetype in ["application/xml", "text/xml"]
def test_detect_mimetype_csv_file():
"""Test detect_mimetype detects CSV files."""
csv_content = b"name,age\nJohn,30\nJane,25"
mimetype = utils.detect_mimetype(csv_content, filename="data.csv")
# CSV might be detected as text/plain, but extension should help
assert mimetype in ["text/csv", "text/plain"]
def test_detect_mimetype_zip_file():
"""Test detect_mimetype detects ZIP files from magic bytes."""
# ZIP magic bytes: PK\x03\x04
zip_content = b"PK\x03\x04\x14\x00\x00\x00"
mimetype = utils.detect_mimetype(zip_content, filename="archive.zip")
assert mimetype == "application/zip"
def test_detect_mimetype_prefers_content_over_extension():
"""Test detect_mimetype prefers content detection when both are available and specific."""
# PDF content but wrong extension
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename="document.txt")
# Should prefer content detection (PDF) over extension (txt)
assert mimetype == "application/pdf"
@pytest.mark.xfail(
reason="Fails in our repo, but passes in the original repo, leaving it there"
)
def test_detect_mimetype_powerpoint_pptx():
"""Test detect_mimetype correctly detects PowerPoint .pptx files."""
# .pptx files are ZIP archives, so content might be detected as application/zip or octet-stream
# But with the extension, it should be detected as PowerPoint MIME type
# Using minimal ZIP-like content that might be detected as generic
pptx_content = b"PK\x03\x04" # ZIP magic bytes (PPTX is a ZIP archive)
mimetype = utils.detect_mimetype(pptx_content, filename="presentation.pptx")
# Should use extension to get PowerPoint MIME type
assert mimetype in [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
]
def test_detect_mimetype_powerpoint_ppt():
"""Test detect_mimetype correctly detects PowerPoint .ppt files (older format)."""
# .ppt files might be detected as application/octet-stream by magic bytes
# But with the extension, it should be detected as PowerPoint MIME type
ppt_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # OLE2 compound document header
mimetype = utils.detect_mimetype(ppt_content, filename="presentation.ppt")
# Should use extension to get PowerPoint MIME type
assert mimetype in [
"application/vnd.ms-powerpoint",
"application/mspowerpoint",
]
def test_detect_mimetype_ole_storage():
"""
Microsoft files .xls .doc .ppt can use the mimetype application/x-ole-storage
for Microsoft OLE2/Compound File Binary Format. In that case we want to rely on the extension
"""
xls_ole_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
mimetype = utils.detect_mimetype(xls_ole_content, filename="document.xls")
assert mimetype == "application/vnd.ms-excel"
+1
View File
@@ -14,6 +14,7 @@ router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register("files", viewsets.FileViewSet, basename="files")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
+111
View File
@@ -7,6 +7,8 @@ Utils functions used in the core app
import hashlib
import json
import logging
import mimetypes
import random
import secrets
import string
@@ -17,7 +19,9 @@ from django.conf import settings
from django.core.files.storage import default_storage
import aiohttp
import boto3
import botocore
import magic
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
@@ -29,6 +33,8 @@ from livekit.api import ( # pylint: disable=E0611
VideoGrants,
)
logger = logging.getLogger(__name__)
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
@@ -344,3 +350,108 @@ def generate_room_slug():
for size in sizes
]
return "-".join(parts)
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
"""
Detect MIME type using multiple methods for better accuracy.
This function combines:
1. Magic bytes detection (python-magic) - most reliable for actual file content
2. File extension detection (mimetypes) - useful as fallback or for validation
Args:
file_buffer: The file content buffer (first bytes of the file)
filename: Optional filename to extract extension from
Returns:
str: The detected MIME type
Notes:
Originally from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
"""
# Initialize magic detector
mime_detector = magic.Magic(mime=True)
# Method 1: Detect from file content (magic bytes) - most reliable
mimetype_from_content = mime_detector.from_buffer(file_buffer)
# If we have a filename, try extension-based detection as well
mimetype_from_extension = None
if filename:
# Use mimetypes module to guess from extension
# Use guess_file_type (Python 3.13+) instead of deprecated guess_type
mimetype_from_extension, _ = mimetypes.guess_file_type(filename, strict=False)
logger.debug("detect_mimetype: mimetype_from_content: %s", mimetype_from_content)
logger.debug(
"detect_mimetype: mimetype_from_extension: %s", mimetype_from_extension
)
# Strategy: Prefer content-based detection, but use extension if:
# 1. Content detection returns generic types (application/octet-stream, text/plain)
# 2. Content detection fails or returns None
# 3. Extension detection provides a more specific type
# Generic/unreliable MIME types that we should try to improve
generic_types = {
"application/octet-stream",
"application/x-ole-storage", # used by .xls, .doc and .ppt
"application/zip",
"text/plain",
}
# If content detection gives us a generic type and we have extension info
if mimetype_from_content in generic_types and mimetype_from_extension:
# Use extension-based detection if it's more specific
if mimetype_from_extension not in generic_types:
return mimetype_from_extension
# If content detection failed, returned None or is a generic type, use extension if available
if not mimetype_from_content or mimetype_from_content in generic_types:
if mimetype_from_extension:
return mimetype_from_extension
# Default to content-based detection (most reliable)
return mimetype_from_content or "application/octet-stream"
def generate_upload_policy(file):
"""
Generate a S3 upload policy for a given file.
Notes:
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long
"""
key = file.file_key
# This settings should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
# Generate the policy
policy = s3_client.generate_presigned_url(
ClientMethod="put_object",
Params={"Bucket": default_storage.bucket_name, "Key": key, "ACL": "private"},
ExpiresIn=settings.AWS_S3_UPLOAD_POLICY_EXPIRATION,
)
return policy
Binary file not shown.
+162 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,141 +29,168 @@ msgstr "Berechtigungen"
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:342
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
#: core/models.py:37
msgid "Member"
msgstr "Mitglied"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Aktiv"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "ID"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "erstellt am"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -171,11 +198,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -183,55 +210,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "Gerät"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "aktiv"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -239,66 +266,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "Benutzer"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Raum"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Räume"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,108 +334,149 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:735
#: core/models.py:742
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:738
#: core/models.py:745
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Anwendung"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:94
#: core/models.py:840
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:848
msgid "Ready"
msgstr "Bereit"
#: core/models.py:854
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:895
msgid "File"
msgstr "Datei"
#: core/models.py:896
msgid "Files"
msgstr "Dateien"
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -536,18 +604,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "Englisch"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "Französisch"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+161 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,139 +29,166 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "No owner"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:342
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
#: core/models.py:37
msgid "Member"
msgstr "Member"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Owner"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Active"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Saved"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "created on"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "updated on"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -169,11 +196,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -181,55 +208,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "device"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "staff status"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "active"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,63 +264,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "user"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "users"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Resource"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Resources"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Room"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -301,111 +328,151 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Recording"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:735
#: core/models.py:742
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:738
#: core/models.py:745
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Application name"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:94
#: core/models.py:840
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:848
msgid "Ready"
msgstr "Ready"
#: core/models.py:854
msgid "Background image"
msgstr "Background image"
#: core/models.py:866
msgid "title"
msgstr "title"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:895
msgid "File"
msgstr "File"
#: core/models.py:896
msgid "Files"
msgstr "Files"
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -533,18 +600,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "English"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "French"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "German"
Binary file not shown.
+163 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,143 +29,171 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:342
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
#: core/models.py:37
msgid "Member"
msgstr "Membre"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Initié"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Actif"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "créé le"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -173,11 +201,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -185,55 +213,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "appareil"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "actif"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -241,65 +269,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "utilisateur"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "utilisateurs"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Ressources"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Salle"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Salles"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,109 +335,150 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:735
#: core/models.py:742
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:738
#: core/models.py:745
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domaine"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:94
#: core/models.py:840
msgid "Pending"
msgstr "En attente"
#: core/models.py:848
msgid "Ready"
msgstr "Prêt"
#: core/models.py:854
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:866
msgid "title"
msgstr "Titre"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895
msgid "File"
msgstr "Fichier"
#: core/models.py:896
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -537,18 +606,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "Anglais"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "Français"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "Allemand"
Binary file not shown.
+161 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,140 +29,167 @@ msgstr "Rechten"
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:342
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
#: core/models.py:37
msgid "Member"
msgstr "Lid"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Actief"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -170,11 +197,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -182,55 +209,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "apparaat"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "actief"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -238,64 +265,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "gebruiker"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Bron"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Ruimte"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -303,108 +330,148 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Opname"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:735
#: core/models.py:742
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:738
#: core/models.py:745
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Applicatie"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domein"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:94
#: core/models.py:840
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:848
msgid "Ready"
msgstr "Klaar"
#: core/models.py:854
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:895
msgid "File"
msgstr "Bestand"
#: core/models.py:896
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -532,18 +599,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "Engels"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "Frans"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "Duits"
+85 -6
View File
@@ -18,6 +18,7 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -27,6 +28,10 @@ from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
def get_release():
"""
@@ -92,7 +97,11 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if values.DatabaseURLValue(
None, environ_name="DATABASE_URL", environ_prefix=None
)
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -116,6 +125,9 @@ class Base(Configuration):
STATIC_ROOT = path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -149,6 +161,48 @@ class Base(Configuration):
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_SIGNATURE_VERSION = values.Value(
"s3v4",
environ_name="AWS_S3_SIGNATURE_VERSION",
environ_prefix=None,
)
AWS_S3_UPLOAD_POLICY_EXPIRATION = values.Value(
60, # 1 minute
environ_name="AWS_S3_UPLOAD_POLICY_EXPIRATION",
environ_prefix=None,
)
AWS_S3_DOMAIN_REPLACE = values.Value(
environ_name="AWS_S3_DOMAIN_REPLACE",
environ_prefix=None,
)
FILE_UPLOAD_ENABLED = values.BooleanValue(
# False to avoid a breaking change for now
default=False,
environ_name="FILE_UPLOAD_ENABLED",
environ_prefix=None,
)
FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
)
FILE_UPLOAD_APPLY_RESTRICTIONS = values.BooleanValue(
default=True, environ_name="FILE_UPLOAD_APPLY_RESTRICTIONS", environ_prefix=None
)
FILE_UPLOAD_RESTRICTIONS = values.DictValue(
{
"background_image": {
"max_size": 2 * MB,
"max_count_by_user": 10,
"allowed_extensions": [".jpeg", ".jpg", ".png"],
"allowed_mimetypes": ["image/jpeg", "image/png"],
},
},
environ_name="FILE_UPLOAD_RESTRICTIONS",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -292,6 +346,9 @@ class Base(Configuration):
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Meet API",
@@ -336,6 +393,9 @@ class Base(Configuration):
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
),
@@ -383,11 +443,20 @@ class Base(Configuration):
THUMBNAIL_ALIASES = {}
# Celery
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
# Defaults to False to avoid breaking change, async task will be run
# synchronously
CELERY_ENABLED = values.BooleanValue(False, environ_prefix=None)
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(False, environ_prefix=None)
CELERY_TASK_DEFAULT_QUEUE = values.Value("meet-backend", environ_prefix=None)
CELERY_BROKER_URL = values.Value("redis://redis:6379/0", environ_prefix=None)
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_ENGINE = values.Value(
default="django.contrib.sessions.backends.cache",
environ_name="SESSION_ENGINE",
environ_prefix=None,
)
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
@@ -585,6 +654,12 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
@@ -656,7 +731,7 @@ class Base(Configuration):
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
converter=int,
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
@@ -906,7 +981,11 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True
FILE_UPLOAD_ENABLED = True
def __init__(self):
# pylint: disable=invalid-name
+42 -37
View File
@@ -2,12 +2,12 @@
# Meet package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.10.9,<0.11.0"]
build-backend = "uv_build"
[project]
name = "meet"
version = "1.2.0"
version = "1.11.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,46 +17,50 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.13",
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.40.69",
"boto3==1.42.49",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.5.3",
"celery[redis]==5.6.2",
"dj-database-url==3.1.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.24",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.9",
"django-pydantic-field==0.5.4",
"django==5.2.12",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"dockerflow==2026.1.26",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"markdown==3.10",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.12.0",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
"sentry-sdk==2.43.0",
"sentry-sdk==2.53.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.2",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
]
[project.urls]
@@ -65,32 +69,33 @@ dependencies = [
"Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet"
[project.optional-dependencies]
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.10.1",
"drf-spectacular-sidecar==2026.1.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
"ruff==0.15.1",
"types-requests==2.32.4.20260107",
]
[tool.setuptools]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv.build-backend]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [
+2365
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,14 +1,16 @@
FROM node:20-alpine AS frontend-deps
USER node
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
COPY --chown=node:node ./src/frontend/package.json ./package.json
COPY --chown=node:node ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
COPY --chown=node:node .dockerignore ./.dockerignore
COPY --chown=node:node ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps AS meet
@@ -17,6 +19,8 @@ WORKDIR /home/frontend
FROM frontend-deps AS meet-dev
USER node
WORKDIR /home/frontend
EXPOSE 8080
@@ -43,7 +47,8 @@ RUN apk update && apk upgrade libssl3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
libpng>=1.6.53-r0 \
&& apk del curl
USER nginx
+6
View File
@@ -5,6 +5,12 @@ server {
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;

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