Compare commits

...

191 Commits

Author SHA1 Message Date
lebaudantoine de50aeb4fe wip hint if the user is authenticated or not in attributes 2026-05-17 23:44:49 +02:00
lebaudantoine 71f76a81e9 ♻️(backend) refactor caller identity getter
Enhance getting the caller's identity to prevent None.
2026-05-17 23:42:54 +02:00
lebaudantoine 385da86759 🔒️(backend) verify participant presence before mute operations
Ensure the participant requesting a mute action is still present
in the room before processing the request.

This mitigates scenarios where a previously issued token could be
reused after the meeting has ended.

Current token lifetime is intentionally long-lived and will be
refactored in the future to better align with LiveKit session
constraints. In the meantime, add this extra validation step to
reduce the attack surface.
2026-05-17 23:39:53 +02:00
lebaudantoine 81e3483f28 📝(changelog) update the changelog 2026-05-17 23:39:53 +02:00
lebaudantoine 5e030c2a07 ♻️(frontend) refactor useMuteParticipant hook
Fix unreachable code when notifying participants that they were
muted.

Prevent unnecessary function re-creations when props remain
unchanged.

Also guard against missing tokens by logging an error and
returning early when the token is undefined.
2026-05-17 23:39:53 +02:00
lebaudantoine 32fbedd358 (backend) extend live synchronization to lobby access level updates
Extend the existing live synchronization mechanism beyond room
configuration to also include lobby access level changes.

This ensures that all owners and admins sharing a room maintain a
consistent and up-to-date view of room state in the frontend,
including configuration and access control updates.
2026-05-17 23:39:53 +02:00
lebaudantoine aab90650f1 (frontend) add synchroniser for room metadata updates
Listen to room metadata change events and synchronize the React
Query cache with the latest room data fetched from the API.

This ensures clients react to live configuration updates, such as
showing or hiding mute controls when `everyone_can_mute` changes.
2026-05-17 23:39:53 +02:00
lebaudantoine 534cf000b2 (backend) expose room configuration to all API consumers
Update room serialization to include room configuration for all
users fetching the API response, not only room owners.
This behavior was inherited from the original upstream project.

At the moment, exposing this configuration does not appear to
introduce meaningful security concerns or provide attackers with
additional capabilities.

The decision will continue to be reviewed from a security
perspective, but sharing the configuration improves frontend
consistency and synchronization.
2026-05-17 23:39:53 +02:00
lebaudantoine 5bac1668fe ♻️(fullstack) simplify source serialization
Simplify source serialization and validation logic while improving
type safety around room configuration handling.

Introduce a dedicated TypeScript type matching the backend
Pydantic model more precisely.

Also harmonize track source casing between frontend and backend to
remove redundant conversion logic and resolve #1282.
2026-05-17 23:39:53 +02:00
lebaudantoine 5a7a0da923 (backend) add synchronization mechanism for room configuration updates
Introduce synchronization of room configuration changes across
active participants.

When a room configuration is updated through a PUT operation, the
backend now performs an additional LiveKit API call to notify room
participants through a room metadata update event.

This ensures admins and owners quickly see up-to-date settings in
their administration panel. It also prepares the frontend for
automatic updates of unprivileged participants room’s data without
refetching it from the API.

An event-driven design was chosen instead of storing the full room
configuration in LiveKit metadata. While embedding the state
directly in metadata would provide immediate synchronization, it
would also require initializing and maintaining configuration
state during room creation or webhook handling, increasing the
risk of operational failures and regressions.

Instead, the backend emits lightweight synchronization events and
active clients update their React Query cache, which remains the
single source of truth for room configuration data.
2026-05-17 23:39:53 +02:00
lebaudantoine c20daafd81 (fullstack) support everyone_can_mute room configuration
Introduce a new room setting controlling whether all participants,
including non-privileged users, can mute others.

Update API validation accordingly and add the frontend controls
allowing administrators to toggle the option and persist the
configuration through the API.
2026-05-17 23:39:53 +02:00
lebaudantoine 9846a61bd0 (frontend) update useCanMute hook to reflect room muting behavior
Allow non-privileged users to mute others when the
everyone_can_mute configuration is unset or true.

This setting is not yet customizable by room owners and will be
introduced in a future update.
2026-05-17 23:39:53 +02:00
lebaudantoine 388b7d172d (frontend) allow unauthenticated participants to mute via LiveKit token
Pass the LiveKit token when calling the mute-participant endpoint
to authenticate the request.

This enables non-authenticated participants to mute others through
the API while preserving proper authorization checks.
2026-05-17 23:39:53 +02:00
lebaudantoine 288562cc0e 🛂(backend) allow participants to mute others based on room configuration
Enable any participant to mute others when the room configuration
allows it. This is enabled by default for all meetings unless
explicitly disabled by an administrator.

Privileged users retain the ability to mute any participant
regardless of the room configuration.
2026-05-17 23:39:53 +02:00
leo 79400188d8 🔊(summary) improve logging of speaker assign
Structure logging of speaker assignment in json format to help
assess its performance.
2026-05-14 15:39:02 +02:00
lebaudantoine dcaa45ccfe 🩹(frontend) fix subtitle background regression
Restore transparent background as the default subtitle background
to match previous behavior.
2026-05-14 15:04:48 +02:00
lebaudantoine 35951ba2a6 🔖(minor) bump release to 1.16.0 2026-05-13 22:30:32 +02:00
lebaudantoine 72184e1370 🩹(frontend) fix spacing regression in mobile control bar
Correct excessive spacing between action buttons in the mobile
control bar introduced by a recent layout change.
2026-05-13 20:15:32 +02:00
leo 1b4a8fbac2 🔧(agents) fix Docker setup
Fix two issues. 1: Missmatch between commands in dev and production in
Dockerfile, leading to unexpected behaviors. 2: Naming of
multi-user-transcriber -> multi-user-transcriber-dev for coherence.
2026-05-13 20:07:45 +02:00
lebaudantoine 1e2fad5444 ️(mail) revert mail upgrade due to unhandled breaking changes
Rollback the mail package upgrade after identifying multiple
breaking changes introduced in v5 that were not fully accounted
for.

Local testing initially missed the issue because the mail Docker
image had not been rebuilt automatically, causing broken emails to
go unnoticed.
2026-05-13 19:55:54 +02:00
leo 96f97ed2d0 (summary) improve speaker assignment
Speaker-to-participant assignment relie on WhisperX word timings, but
incorrect word durations in the output can lead to inaccurate overlap
scoring and wrong user attribution. Add a custom heuristic to trim
overly long word durations before computing assignments.
2026-05-12 16:58:07 +02:00
lebaudantoine 02d16cb55c ⬆️(addons) update dependencies 2026-05-12 16:26:16 +02:00
lebaudantoine 7268ff6777 ⬆️(mail) update dependencies 2026-05-12 16:26:16 +02:00
lebaudantoine cca5bc2186 ⬆️(frontend) update dependencies 2026-05-12 16:26:16 +02:00
leo ec67a12fe4 (agents) use uv for dependency management
Change from pip to uv for dependancy management in src/agents.
2026-05-12 13:47:19 +02:00
leo 05f32d008a ⬆️ (dependencies) Bump urllib3 from 2.6.3 to 2.7.0 [SECURITY]
Fix CVE-2026-44431 and CVE-2026-44432.
2026-05-12 11:23:00 +02:00
UGilfoyle 964b3cd452 🐛(backend) add link to "Open" text in recording email
Added a hyperlink to the "Open" text in step 1 of the recording
notification email instructions. Previously, "Open" was plain text
and users could only access their recording via the button below.
Now the text itself is a clickable link, improving accessibility
for email clients that may not render the button properly.

Updated MJML source template and all 4 locale files (en, fr, de, nl).
2026-05-11 23:04:27 +02:00
Florent Chehab c7ca5a621f 🐛(ci) install ffmpeg for summary tests
Add ffmpeg for summary tests
2026-05-11 23:00:55 +02:00
Florent Chehab 90ebe231ef 🐛(summary) complete webm support
When duration is not reported in the files metadata,
we directly infer the duration from the audio packets.
This prevents errors on webm files.

Very simple audio & video test files have been added
that cover relevant usecases to prevent regressions.
2026-05-11 23:00:54 +02:00
soyouzpanda 04f2a9ebdc ⬆️(mail) fix dependencies not having resolved or integrity field
Update dependencies to the latest minor versions fixed that
by re-resolving the fields.
This is needed for packaging as many distribution retrieve
node modules into the npm cache and then tries to install
node modules into the project without any internet connection.
Since there is no resolved/integrity field, it fails to
get packages from the cache.
2026-05-11 12:45:02 +02:00
renovate[bot] 6a8eb79b41 ⬆️(dependencies) update django to v5.2.14 [SECURITY] 2026-05-11 12:03:18 +02:00
leo bc35046b3a 🩹(summary) fix bug in assign_user
Fix bug in speaker assignment which occurs when LIVEKIT_VERIFY_SSL
is True.
2026-05-07 18:17:15 +02:00
leo 1612d8b2d4 (audio) assign users to diarization speaker results using VAD
Introduce a new user assignment mechanism to for more friendly output
than the current (SPEAKER_0, SPEAKER_1, ...). Use the VAD metadata to
compare speech intervals with those returned by WhisperX. User with the
highest overlap score above a defined threshold is assigned to each segment.
This method allows for multi-speaker scenarios for a single account.
2026-05-07 12:45:00 +02:00
lebaudantoine f8937fc0a1 ♻️(frontend) improve and simplify accessibility font override logic
Fix compatibility issues with the DINUM frontend image, which
overrides the default `font-sans` value.

Simplify the implementation by having the JavaScript layer only
toggle well-scoped CSS classes responsible for accessibility font
overrides. This makes the behavior more predictable and restoring
default styles straightforward.

Also clarify the intent of the hook by making its accessibility
purpose explicit and moving its usage to the App component, where
it better fits the application lifecycle.
2026-05-07 11:20:15 +02:00
Cyril 97b5e3e65c (frontend) add font selector in accessibility settings
Dropdown with description and FR/EN/NL translations.
2026-05-07 11:20:15 +02:00
Cyril b917d82f7e (frontend) apply font preference to app layout
Hook, CSS variable and LiveKit integration for custom fonts.
2026-05-07 11:20:15 +02:00
Cyril 82d146cdf5 (frontend) install accessibility font packages
Lexend, Atkinson Hyperlegible Next and OpenDyslexic via fontsource.
2026-05-07 11:20:15 +02:00
Cyril cbfeea0a4e (frontend) add uiFont preference to accessibility store
Add UiFont type with four options and Extend AccessibilityState.
2026-05-07 11:20:15 +02:00
leo a695758da4 ♻️(summary) refactor tasks signature and make transcription tz-aware
The tasks endpoint used non-timezone-aware date and time values and split
them into separate variables, which is unconventional. Refactor the
implementation to use timezone-aware datetime objects and align transcription
formatting with the user-declared timezone. Update the source of truth for
recording start time to FileInfo.started_at for improved precision. Adjust
the task signature in preparation for upcoming user assignment work, which
will require `started_at`, `ended_at`, and `metadata_filename`.
2026-05-06 18:33:03 +02:00
Damien Laine 4c5b6de8f3 (backend) make LiveKit Egress recording encoding configurable
Expose RECORDING_ENCODING_* settings to override the default LiveKit
Egress preset (H264_720P_30). When RECORDING_ENCODING_ENABLED is True,
the provided width/height/framerate/bitrate/keyframe values are passed
as advanced EncodingOptions. Lowering framerate and bitrate reduces
recording file size and egress worker CPU load.

Disabled by default, preserving current behaviour.
2026-05-05 18:26:49 +02:00
Florent Chehab cf4e347589 (helm) add support multiple transcribe worker / endpoint
Udate the helm chart to support multiple transcribe worker in
the summary service.
This is useful when using multiple WhisperX instances to have one deployment
for each endpoint. This enables some kind of horizontal scaling (we still
keep one call per WhisperX endpoint but can have multiple WhisperX endpoints)
2026-05-05 09:16:50 +02:00
tuanaiseo fc260b2686 🔒️(frontend) room ids are generated with non-cryptographic rand
Room identifiers are created with `Math.random()`, which is predictable
and not suitable for security-sensitive identifiers. Predictable
room IDs increase the risk of room enumeration and unauthorized
access attempts, especially when IDs are part of join URLs.

Affected files: generateRoomId.ts

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
2026-05-04 23:47:47 +02:00
lebaudantoine cd7799997e 🧑‍💻(bin) update release tooling to support uv-based deps management
Following the switch from pip to uv, prepare the release workflow
to automatically run `uv lock` on backend and
keep dependencies up to date.
2026-05-04 22:36:07 +02:00
EpsilonFO a2bccf4f4f 🐛(backend) make start-recording atomic and fault-tolerant
Wrap Recording and RecordingAccess creation in a single transaction so a
partial failure does not leave orphan rows, and return 409 instead of 500
when a recording is already in progress for the room.

When the worker fails to start, transition the Recording to
FAILED_TO_START so the unique partial constraint on (room, status) no
longer blocks future recording attempts on the same room.
2026-05-04 22:15:14 +02:00
Sanjay Santhanam 6830250f2c ♻️(frontend) standardize role terminology across localizations
Fixes #1126 - Inconsistent role terminology in localization files.

Standardize on 'host' as the primary role term across en, de, and nl
locales, replacing mixed usage of 'administrator', 'organizer', 'admin',
'Organisator:in', 'Organisierende', and 'organisator'.
2026-05-04 18:44:20 +02:00
leo 0c0ce87947 🔒️(backend) validate Room configuration with Pydantic schema
Room.configuration accepted arbitrary JSON without validation, allowing unsafe
or malformed payloads to be stored and creating a security risk. Define a
Pydantic schema to enforce structure and constraints, and add validation
at the serializer level to reject invalid inputs.
2026-05-04 18:10:44 +02:00
renovate[bot] 597eba6e8a ⬆️(dependencies) update postcss to v8.5.10 [SECURITY] 2026-05-04 16:48:46 +02:00
renovate[bot] 47dbc271ba ⬆️(dependencies) update webpack-dev-server to v5.2.1 [SECURITY] 2026-05-04 15:34:30 +02:00
renovate[bot] c3adcc8ff3 ⬆️(dependencies) update pytest to v9.0.3 [SECURITY] 2026-05-04 14:24:53 +02:00
lebaudantoine 07698ddced 🔖(minor) bump release to 1.15.0 2026-05-01 00:16:34 +02:00
lebaudantoine cce0a45fbc 🩹(frontend) add missing nonce loading config js in transit
Fix an minor issue.
2026-04-30 20:01:25 +02:00
lebaudantoine da1767c261 ♻️(frontend) refine Nginx config for DINUM frontend
Lint and clean up the Nginx configuration.
Fix an issue affecting static asset serving.
2026-04-30 18:56:15 +02:00
lebaudantoine dd3d47afe6 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
Updating connectionObserverSnapshot triggered page re-renders,
causing participants to reconnect due to a race condition.

Read directly from the underlying store instead of using the
snapshot to avoid unnecessary re-renders.
2026-04-30 18:32:43 +02:00
lebaudantoine ac2eddc10f 🩹(addons) fix minor issues in Outlook add-in alpha
Address issues identified during testing with external users to
improve stability and usability of the alpha version.
2026-04-30 17:03:04 +02:00
lebaudantoine 35863ec3b6 🩹(frontend) fix access control for screen recording feature flag
Ensure screen recording is properly disabled when the feature
flag is set to false.

Previously, no feature flag was enforced, allowing unintended
access.

Also update the blocked-access message to be more generic and not
specific to public sector users.
2026-04-30 13:38:10 +02:00
lebaudantoine 3d125e940f 🩹(frontend) rename Nginx config to override default configuration
Rename the configuration file to `default.conf` so it replaces
the default config bundled in the Nginx image.

Handles properly 404 path.
2026-04-30 00:21:31 +02:00
lebaudantoine 6f38d60a27 (backend) support add-ons authentication in external viewset
Integrate the add-ons authentication backend into the externalviewset.

This allows third-party integrations (e.g. calendar add-ins) to
obtain a JWT for a user and use it to call the API (e.g. create a
room) via a Bearer authorization header.
2026-04-29 20:32:27 +02:00
lebaudantoine 012857f8c6 🔧(backend) add setting to toggle application token exchange mechanism
Introduce a configuration flag to enable or disable the
application token exchange (service account) mechanism.

This allows activating alternative authentication backends
without requiring full application token configuration.

Required to support the upcoming add-ons authentication backend.
2026-04-29 20:32:25 +02:00
lebaudantoine e5a804f748 🩹(backend) fix scope typo addons settings
The external viewset expected a plural "rooms:x" scope instead of
the incorrect singular "room:x".

Update it to accept the proper scope and emit tokens accordingly.
2026-04-29 20:32:04 +02:00
lebaudantoine 6bb8084aa0 🔖(helm) release new chart version for Outlook add-in support
Bundle configuration maps required for the Outlook add-in into a
new chart version.
2026-04-29 16:03:51 +02:00
lebaudantoine 4548f69de8 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
Provide the minimal components required to support an Outlook
add-in: user authentication, JWT retrieval, and API calls to
generate meeting links.

This implementation is an early alpha: developer experience is
limited, documentation is incomplete, and the solution is not
white-labeled.

It's too early to consider these parts ready to ship into
production.

As a result, it is currently only available within the DINUM
frontend image.
2026-04-29 16:03:48 +02:00
Cyril 1a3a92f901 Merge branch 'fix/transcription-audio-link-label' 2026-04-29 13:44:29 +02:00
lebaudantoine 181b97b310 ♻️(backend) align CSRF token header with Django conventions
Update the CSRF header naming to follow Django standards,
avoiding duplicated client-side logic with inconsistent
header names.
2026-04-29 11:21:10 +02:00
Cyril bb816eb2e5 💬(backend) clarify french transcription audio download link text
Replace FR locale link label so it clearly identifies the audio file.
2026-04-29 07:41:34 +02:00
lebaudantoine 6ee89b201e 🧑‍💻(backend) enable add-ons by default in Tilt stack
Activate the add-ons feature by default in the Tilt development
environment to simplify local testing and integration.
2026-04-24 09:27:18 +02:00
lebaudantoine ecb710688d (backend) introduce add-ons authentication backend
Add a new authentication backend for add-ons, with a core service
managing session state in cache and exposing three API endpoints.

Microsoft Add-ins recommend launching authentication from the
side panel via a dialog, then returning the JWT to the parent
context using postMessage. However, due to Django and SSO security
constraints (window.opener not preserved), this approach is not
viable.

Implement a three-step authentication flow:

- `/init`: create a session, return a short-lived transit token
  and CSRF token. Store session ID in a secure HttpOnly cookie.
- `/poll`: allow the add-on to poll until authentication is
  complete, then consume the session and clear the cookie.
- `/exchange`: exchange the transit token for a JWT, which is
  later retrieved via the `/poll` endpoint.

The add-on opens an authentication dialog, stores the transit
token in sessionStorage, and performs the exchange after login.

This approach works within iframe constraints and provides a
sufficiently secure v0 despite known limitations.
2026-04-24 09:27:18 +02:00
lebaudantoine 5e1e05b001 🩹(frontend) use a more standard (quality) rating scale
A 1–7 scale is not commonly used in software products. Feedback
from both users and the support team suggests reducing the number
of options to simplify usage and analysis.

Adopting a 1–5 scale improves usability and makes responses
easier to interpret and process.

The scale has to be odd.
2026-04-23 15:20:54 +02:00
lebaudantoine 211e97edfa ⬆️(backend) bump django-lasuite to v0.0.26
Upgrade the package to support the `login_hint` parameter.
2026-04-23 11:49:52 +02:00
Florent Chehab c4fc46727c 💚(summary) add ruff ignore on ffprobe run
It's hard to know the ffprobe path before hand depending on the
environment, so I prefer to ignore the linting error.
2026-04-23 11:49:40 +02:00
Florent Chehab 28acbb5459 🐛(summary) support webm
Mutagen lib doesn't support webm files.
Since we have ffmpeg installed I just switch to
using ffprobe for a broader support.
2026-04-21 17:54:04 +02:00
lebaudantoine 5a81e2b92c 📌(agents) pin Docker image to a specific tag for reproducible builds
Avoid using floating tags and pin the image to an explicit version
to ensure consistent and reproducible agent builds.
2026-04-17 15:35:20 +02:00
lebaudantoine df24aaab71 🔖(helm) release chart 0.0.20 2026-04-17 12:28:42 +02:00
lebaudantoine 3b474ba1c0 ♻️(backend) control metadata collector agent launch via feature flag
Allow controlling when the metadata collector agent is started,
enabling users to try the feature and disable it if needed.

Introduce a user-level feature flag to toggle the agent for the
initial release.
2026-04-17 12:17:27 +02:00
leo fc4b6d679a ♻️(devex) update Makefile for metadata-collector-dev
Add support for the new metadata collector in the Makefile.
2026-04-17 12:17:27 +02:00
leo 73dd684c8d 🔧(build) update docker, helm, and compose for MetadataCollectorService
Updated Dockerfile, Helm charts, and Docker Compose configuration to integrate
the newly introduced MetadataCollectorService.
2026-04-17 12:17:27 +02:00
leo 8507cdd2b6 (backend) add metadata collection of VAD, connection and chat events
Introduce MetadataCollector and MetadataCollectorService classes to
centralize the collection and storage of user connections, VAD events,
and chat messages. This creates a structured foundation for future speaker
assignment logic based on voice activity detection. Add tests for this new
feature.
2026-04-17 12:17:27 +02:00
lebaudantoine aaf21e97e8 🔖(minor) bump release to 1.14.0 2026-04-16 22:12:48 +02:00
lebaudantoine bd3a26a2af 📈(frontend) track WebRTC peer candidates in PostHog events
Capture selected ICE candidates for both subscriber and publisher
peer connections.

This enables correlation between survey feedback and connectivity
setup, helping identify problematic network configurations.
2026-04-16 15:45:07 +02:00
lebaudantoine 4d222e4ab4 ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
Bump the base image to Alpine 3.23 to resolve most vulnerabilities
reported by Cyberwatch and Trivy.

Remaining issues require manual updates:
- musl / musl-utils: upgrade to 1.2.5-r11 (CVE-2026-40200)
- zlib: upgrade to 1.3.2-r0 (CVE-2026-22184)
2026-04-16 15:06:28 +02:00
lebaudantoine b80c46da54 ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
Run `uv lock --upgrade` to update transitive dependencies and
resolve the vulnerability in Pillow.

Upgrade Pillow from 12.1.1 to 12.2.0 to address the FITS GZIP
decompression bomb issue.
2026-04-16 12:36:03 +02:00
Florent Chehab 451be40bb7 🐛(summary) relax whisperX payload format
Sometimes whisperX response is partial, we don't
want to crash in such case.
2026-04-15 10:11:36 +02:00
Florent Chehab 45c5a443fb 🐛(summary) fix failure webhook notification
Computation was off by 1.
Also improve the logging.
2026-04-15 10:11:35 +02:00
renovate[bot] 34f9dea73f ⬆️(dependencies) update pytest to v9.0.3 [SECURITY] 2026-04-14 10:53:11 +02:00
Cyril f0fda145d9 ️(frontend) set explicit document title on recording download page
RecordingDownload now updates the tab title per state
2026-04-13 20:18:31 +02:00
Cyril d12ced352a ️(frontend) refocus reactions toolbar with shortcut when already open
Shortcut now opens it or moves focus to the first emoji button
2026-04-13 20:14:40 +02:00
Florent Chehab 497b45f2ca (summary) allow more file extensions
Allow more file extensions by default.
2026-04-13 20:10:50 +02:00
lebaudantoine 52fbd56666 🩹(make) fix indentation in Makefile
Correct indentation issues introduced while updating Kubernetes
commands, ensuring targets execute properly.
2026-04-13 11:06:40 +02:00
lebaudantoine 170763a1f7 ️(frontend) optimize PostHog survey usage and enrich event metadata
Replace costly PostHog surveys with basic surveys, which better
fit our headless usage and avoid short data retention limits.

Enhance emitted events with additional metadata, including a
unique session ID and room ID, to correlate survey responses with
specific sessions.

This lays the groundwork for further enrichment with participant
connection data.
2026-04-09 19:11:33 +02:00
lebaudantoine 037166fb21 🧑‍💻(devex) ensure Kubernetes secrets are initialized for Tilt stack
Automatically copy required Kubernetes secrets when using the
Tilt development stack, as `make bootstrap` is not documented
as a prerequisite.

Feedback from Arnaud Robin
2026-04-09 10:24:45 +02:00
lebaudantoine 6374e136d8 🧑‍💻(devex) remove deprecated external secrets fetch command
Clean up obsolete command used to retrieve external secrets, as
the Helm dev stack relying on it has been removed.
2026-04-09 10:24:45 +02:00
lebaudantoine 3ccb2d4dd8 ♻️(backend) fix Twirp error mocking in tests
Tests were incorrectly mocking Twirp errors using HTTP status
codes instead of the meaningful error codes returned by the
LiveKit SDK.

Update mocks to reflect actual SDK behavior.
2026-04-09 00:37:56 +02:00
lebaudantoine 5d7a54e809 ♻️(backend) use Authorization header for LiveKit token authentication
Replace passing the LiveKit JWT in the request body with the
Authorization header, following standard authentication practices.

Extend the LiveKit authentication backend usage across additional
endpoints.

This also raises questions about how clients should securely
retrieve LiveKit tokens, to be addressed later.
2026-04-09 00:37:43 +02:00
lebaudantoine 07af7a85ff 🥅(backend) refine Twirp error handling for participant operations
Avoid mapping all Twirp errors to generic 500 responses.

Explicitly handle the case where a participant is no longer in
the room, as this may indicate suspicious behavior or a client
state issue.

Improve error discrimination to provide more accurate responses.
2026-04-09 00:37:42 +02:00
lebaudantoine 6180ac4e4f 🔒️(backend) rely on backend to allow participant update their metadata
Introduce toggle-hand and rename endpoints in RoomViewSet,
secured with LiveKit token authentication.

Remove direct permission for clients to update their own metadata
via LiveKit tokens to prevent spoofing (e.g. faking admin status).

Proxy participant metadata updates through the backend to enforce
proper validation and authorization.

Signed-off-by: lebaudantoine <lebaud.antoine131@gmail.com>
2026-04-09 00:37:42 +02:00
renovate[bot] a30b573d36 ⬆️(dependencies) update django to v5.2.13 [SECURITY] 2026-04-09 00:36:28 +02:00
dependabot[bot] 83b95c5520 ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
Bumps [pygments](https://github.com/pygments/pygments) from 2.19.2 to 2.20.0.
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0)

---
updated-dependencies:
- dependency-name: pygments
  dependency-version: 2.20.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 15:14:24 +02:00
dependabot[bot] 0c9b83c793 ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
Bumps [hono](https://github.com/honojs/hono) from 4.12.8 to 4.12.12.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.8...v4.12.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 15:13:05 +02:00
leo 812d80c4f2 (docker) add compose support for multi-user-transcriber
The multi-user transcriber service was not integrated into Docker Compose,
limiting its usability in local and development environments.
Add the necessary configuration to enable running and orchestrating the
multi-user transcriber via Compose.
2026-04-08 15:09:50 +02:00
dependabot[bot] 34212be6e2 ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
Update indirect dependency to include latest fixes and improvements.

See release notes and commits for detailed changes.

Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 19:31:38 +02:00
renovate[bot] d8ccd02bb2 ⬆️(dependencies) update vite to v7.3.2 [SECURITY] 2026-04-07 19:12:24 +02:00
renovate[bot] 08aa63ecb2 ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY] 2026-04-07 17:22:56 +02:00
lebaudantoine bbc8f61221 🔖(helm) release chart 0.0.19 2026-04-07 14:38:18 +02:00
lebaudantoine 6b656eefd7 (backend) add unit tests for JwtTokenService
Introduce minimal unit test coverage for the JwtTokenService
to ensure its core behavior is validated.
2026-04-02 23:18:39 +02:00
fheslouin 264f267ac3 🔒️(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-04-02 13:49:22 +02:00
Florent Chehab 4bf3ba4c48 🔖(helm) release chart 0.0.18 2026-04-02 10:34:13 +02:00
Florent Chehab 4fdc2eee11 📝(backend) move and improve summary method documentation
Quick change post PR review.
2026-04-01 17:43:43 +02:00
Florent Chehab 19c2a378e7 (summary) taskV2 closer to target API gateway contract
Updated taskV2 API contract to be closer to the target gateway contract.
GET operations return the same things as the webhook payload.
Also store the summary on S3 to be iso with transcript.
2026-04-01 17:43:43 +02:00
Florent Chehab 5a70604f01 (summary) add multi-tenant support and v2 tasks / API
Add multitenancy support to Summary sub-app. The V1 routes / tasks
behave like before, with the default tenant being "meet".

V2 routes / tasks support being called frm any tenant, and don't have
meet related logic.
V2 tasks are created in separate queues to avoid mix / match,i
2026-04-01 17:43:43 +02:00
lebaudantoine 7e422e5846 🔖(minor) bump release to 1.13.0 2026-04-01 10:24:36 +02:00
lebaudantoine d915b93caa 🚨(backend) fix InsecureKeyLengthWarning in test suite
Resolve warnings raised in jwt/api_jwt.py:365 by ensuring test
keys meet the required security length.

Align test configuration with expected cryptographic standards.
2026-03-28 16:18:16 +01:00
dependabot[bot] 9d9ec794aa Bump cryptography from 46.0.5 to 46.0.6 in /src/backend
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.6.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 15:44:38 +01:00
dependabot[bot] 570d57d5f5 ⬆️ Bump requests from 2.32.5 to 2.33.0 in /src/summary
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 15:39:28 +01:00
dependabot[bot] 7469ccfdf1 ⬆️ Bump requests from 2.32.5 to 2.33.0 in /src/backend
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 15:36:22 +01:00
Florent Chehab 0d3bd2727e 🧑‍💻(tilt) load secret vars from new env file
To avoid commiting secrets, I am introducing a new kube-secret
env file that is loaded by tilt as a secret. Dev helm
values are updated accordingly.
2026-03-28 10:36:37 +01:00
lebaudantoine 660b022eb1 ️(frontend) enhance sidepanel navigation accessibility
Mark the more options area as an explicit navigation
region for screen readers.

Update each sidepanel toggle to use `aria-expanded` to indicate
whether the panel is open, improving accessibility feedback.

Also, avoid render for screen reader the number of participants
as it's already added the the aria label
2026-03-28 00:06:34 +01:00
lebaudantoine a28b611ecc ️(frontend) add explicit region for call controls
Declare a dedicated ARIA region for call controls
to improve accessibility.

Extract this region into a reusable component for better
consistency and maintainability.
2026-03-28 00:06:33 +01:00
lebaudantoine dfa6092c72 ️(frontend) improve accessibility of the reaction toolbar
Add an aria-label to clearly describe the purpose of the toolbar.

Avoid redundant wording in reaction button labels to improve
clarity for screen reader users.
2026-03-28 00:06:33 +01:00
lebaudantoine 7d8c166c7f ♻️(frontend) introduce persistent reaction toolbar
Inspired by proprietary solutions.

Replace the dialog-based reaction UI with a toolbar integrated
directly into the DOM.

Allow it to remain open and support proper keyboard interaction,
improving accessibility and user experience.
2026-03-28 00:06:33 +01:00
lebaudantoine f7dd1f8fd7 ♻️(frontend) extract layout constants for alignment
Move hardcoded values used for layout alignment and animations
between the videoconference and side panel into shared constants.

This improves readability and makes future adjustments easier to
maintain.
2026-03-28 00:06:33 +01:00
lebaudantoine 416411b843 ♻️(frontend) simplify videoconference layout and clarify component roles
Remove unnecessary wrapper divs to reduce layout complexity.

Explicitly name components to better reflect their
responsibilities, including RoomContentArea which handles the
video track viewport.
2026-03-28 00:06:33 +01:00
lebaudantoine 45e0665cf0 ♻️(frontend) extract layout components into a dedicated feature
Group layout-related components under a single feature directory
to improve structure and readability.

This is a first step toward cleaning up the project and clarifying
the organization of the video call layout.
2026-03-28 00:06:32 +01:00
lebaudantoine a3eabf8f66 ♻️(frontend) move reaction-related code into a dedicated feature folder
Group all reaction components, hooks, and logic under a single
feature directory to improve code organization and maintainability.
2026-03-28 00:06:32 +01:00
lebaudantoine 7c81947681 ♻️(frontend) refactor reaction system to unify state and rendering
Use a single store, hook, and portal system to handle both local
and remote emoji reactions.

Improve code quality and reduce duplication through better
factorization of shared logic.
2026-03-28 00:06:32 +01:00
lebaudantoine 2424817523 🔥(tilt) remove tooling for Tilt development stack
Clean up unused development tooling related to running
the Tilt dev environment.
2026-03-27 23:48:33 +01:00
lebaudantoine 15133f9d6b 🧑‍💻(helm) use YAML anchors to simplify Helm values for summary
Reduce duplication by introducing YAML anchors for configurations
shared across multiple services.

Most settings were nearly identical across the three summary
services, making them easier to maintain and update.
2026-03-25 13:43:19 +01:00
lebaudantoine bea1f18ab8 🗑️(helm) remove unused dev Helm values
The dev values are no longer in use and have not been used for over a year.
We primarily rely on the dev-keycloak values, and occasionally
the dev-dinum ones for testing on the Dinum-labeled frontend.

As a result, the unused dev values should be removed to reduce clutter
and simplify maintenance.
2026-03-25 13:29:57 +01:00
lebaudantoine d5a614d2b5 🐛(backend) fix regression in update-participant endpoint
Serialization hardening introduced a breaking change between the
frontend and backend. Adjust the Pydantic model to restore
compatibility.

Reinstate support for can_subscribe_metric, which is passed by
default from the frontend.
2026-03-25 12:20:45 +01:00
lebaudantoine 108db2e3e5 📌(backend) pin brevo-python to v2.x.x. in renovate
The SDK introduced breaking changes in newer versions. Lock the
dependency to v2.x.x to maintain compatibility.
2026-03-25 11:18:58 +01:00
renovate[bot] 73496406e8 ⬆️(dependencies) update python dependencies 2026-03-25 11:18:57 +01:00
lebaudantoine c5c96369c8 🔊(backend) remove email addresses from invitation failure logs
Email addresses are PII and should not appear in technical or
error logs.

Sanitize logging to avoid exposing sensitive user data when
invitation sending fails.
2026-03-25 09:52:43 +01:00
lebaudantoine e9f90e95b1 🔒️(backend) fix email disclosure in room invitation endpoint
Prevent invited participants from seeing each other's email
addresses when sending room invitations.

Ensure invitations are sent with proper isolation to avoid
mass PII disclosure.

This mitigates risks of email harvesting, spam, and phishing
through the platform.
2026-03-25 09:52:43 +01:00
lebaudantoine f57fbf2d35 🔖(minor) bump release to 1.12.0 2026-03-24 23:37:41 +01:00
Martin Weinelt 920f4558fc 🐛(backend): fix module inclusion with uv-build
After migrating to uv-build only the module matching the project name was
included in sdist/wheel packages. Without a src layout additional modules
need to be tracked manually to ship them in built packages.
2026-03-24 16:14:31 +01:00
lebaudantoine 9df901b9d6 📝(backend) clarify trailing slash requirement in API Swagger doc
Specify that POST routes require a trailing slash to avoid
confusion and incorrect usage from API consumers.
2026-03-24 15:31:58 +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
314 changed files with 37057 additions and 3362 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__
**/*.pyc
venv
.venv
**/.venv
# System-specific files
.DS_Store
+48 -33
View File
@@ -12,6 +12,9 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -20,6 +23,8 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -43,12 +48,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -63,6 +68,8 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -86,12 +93,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -107,6 +114,8 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -130,12 +139,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -151,6 +160,8 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -174,13 +185,13 @@ 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 }}'
-
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
@@ -197,6 +208,8 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -220,14 +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: 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
@@ -242,6 +255,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
+64 -19
View File
@@ -124,15 +124,17 @@ jobs:
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
@@ -148,13 +150,14 @@ jobs:
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 .
lint-summary:
runs-on: ubuntu-latest
@@ -221,8 +224,6 @@ jobs:
DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
@@ -279,10 +280,10 @@ jobs:
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: |
@@ -290,10 +291,54 @@ 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:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
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"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- 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
+1
View File
@@ -31,6 +31,7 @@ MANIFEST
# Translations # Translations
*.pot
*.mo
# Environments
.env
+141 -2
View File
@@ -10,24 +10,163 @@ and this project adheres to
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
## [1.16.0] - 2026-05-13
### Added
- 🔒️(backend) add validation of Room.configuration
- ✨(helm) add support multiple transcribe worker / endpoint #1247
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
- ✨(summary) add speaker-to-participant assignment
### Changed
- ♻️(summary) change tasks endpoint signature
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
- 🧑‍💻(agents) use `uv` for package management
- ✨(summary) improve speaker-to-participant assignment
### Fixed
- ♻(frontend) standardize role terminology across localizations
- 🐛(backend) make start-recording atomic and fault-tolerant
- 🔒️(frontend) room ids are generated with non-cryptographic rand
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
- 🐛(summary) complete webm support #1328
- 🐛(backend) add link to "Open" text in recording email
- 🩹(frontend) fix spacing regression in mobile control bar
## [1.15.0] - 2026-04-30
### Added
- ✨(backend) add metadata collection of VAD, connection and chat events
- ✨(backend) introduce add-ons authentication backend
- 💬(backend) clarify french transcription audio download link text #1299
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
- 🔧(backend) add setting to toggle application token exchange mechanism
- ✨(backend) support add-ons authentication in external viewset
### Fixed
- 🐛(summary) support webm #1290
- ⬆️(backend) bump django-lasuite to v0.0.26
- 🩹(frontend) use a more standard (quality) rating scale
- 🩹(frontend) fix access control for screen recording feature flag
- 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
## [1.14.0] - 2026-04-16
### Added
- 🔒️(helm) Add pod and container securityContext #1197
- ✨(summary) add routes v2 for async STT and summary tasks #1171
- ✅(backend) add unit tests for JwtTokenService #1232
### Changed
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
- ♻️(backend) use Authorization header for LiveKit token authentication
- 🥅(backend) refine Twirp error handling for participant operations
- ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261
- ♿️(frontend) add customizable accessibility fonts #1270
### Fixed
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
- 🔒(backend) rely on backend to allow participant update their metadata
- 🐛(summary) fix failure webhook notification #1233
- 🐛(summary) relax whisperX payload format #1233
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
## [1.13.0] - 2026-03-31
### Changed
- ⬆️(dependencies) update python dependencies
- ♿️(frontend) add explicit region for call controls #1216
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
### Fixed
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
- 🐛(backend) fix regression in update-participant endpoint #1204
## [1.12.0] - 2026-03-24
### Changed
- ♻️(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 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
+39 -23
View File
@@ -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 ----
@@ -42,17 +56,17 @@ RUN apk add \
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
@@ -81,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.
@@ -103,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
@@ -115,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"]
+40 -13
View File
@@ -73,7 +73,9 @@ create-env-files: \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary
env.d/development/summary \
env.d/development/kube-secret \
env.d/development/multi_user_transcriber
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -94,6 +96,7 @@ bootstrap: \
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build
build-backend: ## build the app-dev container
@@ -105,6 +108,10 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber-dev
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -125,10 +132,24 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent
@$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector
.PHONY: run-agents
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
.PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
.PHONY: run-agent-metadata-collector
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -191,6 +212,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
@@ -203,6 +225,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
@@ -223,7 +250,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
@@ -259,6 +286,12 @@ env.d/development/kc_postgresql:
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
# -- Internationalization
env.d/development/crowdin:
@@ -346,21 +379,15 @@ frontend-i18n-generate: \
# -- K8S
build-k8s-cluster: ## build the kubernetes cluster using kind
./bin/start-kind.sh
.PHONY: build-k8s-cluster
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
build-k8s-cluster: \
env.d/development/kube-secret \
./bin/start-kind.sh
.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
+13 -4
View File
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
@@ -34,10 +34,11 @@ docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
only=['./src/frontend', './src/addons', './docker', './.dockerignore'],
target = 'frontend-production',
live_update=[
sync('../src/frontend', '/home/frontend'),
sync('../src/addons', '/home/addons'),
]
)
clean_old_images('localhost:5001/meet-frontend-dinum')
@@ -95,14 +96,22 @@ docker_build(
)
clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
load('ext://secret', 'secret_yaml_generic')
k8s_yaml(secret_yaml_generic(
name="secret-dev",
from_env_file="../env.d/development/kube-secret"
))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', 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'])
+7
View File
@@ -101,6 +101,12 @@ update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Run uv lock in backend
print_info "Running uv lock in backend..."
cd "src/backend"
uv lock
cd -
# Update summary pyproject.toml
update_python_version "summary"
@@ -149,6 +155,7 @@ echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
+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 "$@"
+38 -1
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,6 +80,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- postgresql
- mailcatcher
@@ -105,6 +106,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app-dev
@@ -244,6 +246,41 @@ services:
depends_on:
- redis
metadata-collector-dev:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
environment:
- LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=secret
- AWS_S3_ENDPOINT_URL=minio:9000
- AWS_S3_ACCESS_KEY_ID=meet
- AWS_S3_SECRET_ACCESS_KEY=password
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
- /app/.venv
depends_on:
- livekit
- minio
develop:
watch:
- action: rebuild
path: ./src/agents
multi-user-transcriber-dev:
build:
context: ./src/agents
target: development
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
+28 -8
View File
@@ -38,16 +38,32 @@ COPY ./docker/dinum-frontend/assets/ \
COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Addons builder image ----
FROM node:20-alpine AS addons-builder
WORKDIR /home/addons/outlook
COPY ./src/addons/outlook/package.json ./package.json
COPY ./src/addons/outlook/package-lock.json ./package-lock.json
RUN npm ci
COPY ./src/addons/outlook/ .
RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 \
libcrypto3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
# Security patches for known CVEs
RUN apk update && apk upgrade \
musl \
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
USER nginx
@@ -59,7 +75,11 @@ COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY --from=addons-builder \
/home/addons/outlook/dist \
/usr/share/nginx/html/addons/outlook
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
+82
View File
@@ -0,0 +1,82 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
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";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
# 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;
}
+3 -4
View File
@@ -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.
+1 -1
View File
@@ -71,7 +71,7 @@ backend:
# 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
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
+58
View File
@@ -100,6 +100,13 @@ sequenceDiagram
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook
@@ -141,3 +148,54 @@ Using default project meet
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage.
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+1
View File
@@ -190,6 +190,7 @@ paths:
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/:
post:
tags:
- Rooms
+1
View File
@@ -113,6 +113,7 @@ paths:
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/:
post:
tags:
- Rooms
+17 -1
View File
@@ -27,7 +27,8 @@ 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:8083
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -67,9 +68,24 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Telephony
ROOM_TELEPHONY_ENABLED=True
# Metadata
METADATA_COLLECTOR_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
+4
View File
@@ -0,0 +1,4 @@
WHISPERX_BASE_URL=https://configure-your-url.com
WHISPERX_API_KEY=<key>
LLM_BASE_URL=https://configure-your-url.com
LLM_API_KEY=<key>
@@ -0,0 +1,9 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=kyutai
ENABLE_SILERO_VAD=False
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
+6
View File
@@ -36,6 +36,12 @@
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+8
View File
@@ -0,0 +1,8 @@
{
"plugins": [
"office-addins"
],
"extends": [
"plugin:office-addins/recommended"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": false
}
}
],
]
}
+190
View File
@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.1.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
<Description DefaultValue="Ajoutez facilement un lien de réunion __APP_NAME__ à vos emails et événements Outlook."/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
<SupportUrl DefaultValue="https://lasuite.crisp.help/fr/category/visio-15sakkg/"/>
<AppDomains>
<AppDomain>https://localhost:3000/</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
<RequestedHeight>250</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
+15969
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"name": "office-addin-taskpane-js",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/suitenumerique/meet.git"
},
"license": "MIT",
"config": {
"app_to_debug": "outlook",
"app_type_to_debug": "desktop",
"dev_server_port": 3000
},
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"dev-server": "webpack serve --mode development",
"lint": "office-addin-lint check",
"lint:fix": "office-addin-lint fix",
"prettier": "office-addin-lint prettier",
"signin": "office-addin-dev-settings m365-account login",
"signout": "office-addin-dev-settings m365-account logout",
"start": "office-addin-debugging start manifest.xml",
"stop": "office-addin-debugging stop manifest.xml",
"validate": "office-addin-manifest validate manifest.xml",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "^3.36.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.1"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
"last 2 versions",
"ie 11"
]
}
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title data-app-name></title>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body></body>
</html>
+117
View File
@@ -0,0 +1,117 @@
/* global Office */
const { createRoom, initSession } = require("../common/api");
const { startPolling } = require("../common/polling");
const { saveSession, loadSession } = require("../common/session");
const { openTransitDialog } = require("../common/transitDialog");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { applyAppName } = require("../common/helpers");
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
});
function notify(message) {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message,
persistent: false,
icon: "Icon.16x16",
});
}
function insertMeetingLink(event, session) {
createRoom(session)
.then((data) => {
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
return new Promise((resolve, reject) => {
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de lecture : ${getResult.error.message}`);
resolve();
return;
}
const newBody = getResult.value + message;
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur d'insertion : ${setResult.error.message}`);
resolve();
return;
}
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify("Lien de réunion inséré !");
resolve();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de localisation : ${locationResult.error.message}`);
} else {
notify("Lien de réunion inséré !");
}
resolve();
});
});
});
});
})
.catch((err) => {
notify(`Erreur : ${err.message}`);
})
.finally(() => {
event.completed();
});
}
function connect(event) {
initSession()
.then((data) => {
const stopPolling = startPolling(data.csrf_token, {
onSuccess: (sessionData) => {
saveSession(sessionData).then(() => {
insertMeetingLink(event, sessionData);
});
},
onTimeout: () => {
notify("Connexion expirée, veuillez réessayer.");
event.completed();
},
onError: (err) => {
notify("Une erreur est survenue, veuillez ré-essayer");
event.completed();
},
});
openTransitDialog(data.transit_token, {
onCancel: () => {
stopPolling();
event.completed();
},
onError: (err) => {
stopPolling();
event.completed();
},
});
})
.catch((err) => {
notify(`Erreur : ${err.message}`);
event.completed();
});
}
function generateMeetingLink(event) {
const session = loadSession();
if (session?.access_token) {
insertMeetingLink(event, session);
} else {
connect(event);
}
}
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLink);
Office.actions.associate("generateMeetingLinkFromMail", generateMeetingLink);
+82
View File
@@ -0,0 +1,82 @@
const { URLS } = require("./urls");
function getCsrfToken() {
return document.cookie
.split(";")
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
.map((cookie) => cookie.split("=")[1])
.pop();
}
function authHeaders(session) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
};
}
/**
* Builds headers for CSRF-protected requests.
*
* Two CSRF flows coexist in this addon:
*
* 1. Cookie-based (Django default): used by `exchange`, called from the
* OAuth success page in a normal browser context. Django's CSRF
* middleware has already set the `csrftoken` cookie via the auth
* redirect, so we read it from `document.cookie` and echo it back
* as `X-CSRFToken`. The middleware verifies the header matches the
* cookie. No `csrfToken` argument needed — `getCsrfToken()` handles it.
*
* 2. Body-passed token: used by `poll`, called from the Office dialog /
* taskpane iframe. Cookie access inside Office iframes is unreliable
* across Outlook clients, so we can't depend on `document.cookie`
* being populated. Instead, `init` returns the CSRF token in its JSON
* response body, and callers pass it explicitly to subsequent calls.
* The token still travels as `X-CSRFToken` — only its source differs.
*
* The `csrfToken` parameter takes precedence when provided; falls back
* to the cookie when omitted.
*/
function csrfHeaders(csrfToken) {
const token = csrfToken || getCsrfToken();
return {
"Content-Type": "application/json",
...(token && { "X-CSRFToken": token }),
};
}
async function request(path, { session, csrf, csrfToken, ...opts } = {}) {
const headers = {
...(session && authHeaders(session)),
...(csrf && csrfHeaders(csrfToken)),
...opts.headers,
};
const res = await fetch(path, {
...opts,
headers,
credentials: csrf ? "include" : opts.credentials,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
module.exports = {
initSession: () => request(URLS.init, { method: "POST" }),
pollSession: (csrfToken) =>
request(URLS.poll, {
method: "POST",
csrf: true,
csrfToken,
}),
exchangeSession: (transitToken) =>
request(URLS.exchange, {
method: "POST",
csrf: true,
body: JSON.stringify({ transit_token: transitToken }),
}),
createRoom: (session) =>
request(URLS.rooms, {
method: "POST",
session,
}),
};
+16
View File
@@ -0,0 +1,16 @@
const { APP_NAME } = require("./index");
function isOfficeReady() {
return typeof Office !== "undefined" && Office?.context?.roamingSettings != null;
}
function applyAppName() {
document.querySelectorAll("[data-app-name]").forEach((el) => {
el.textContent = APP_NAME;
});
}
module.exports = {
isOfficeReady,
applyAppName,
};
+7
View File
@@ -0,0 +1,7 @@
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
module.exports = {
BASE_URL,
APP_NAME,
};
@@ -0,0 +1,52 @@
const { APP_NAME } = require("./index");
function _formatPin(pin) {
if (!pin) return "";
const clean = String(pin).replace(/\s+/g, "");
if (!clean) return "";
if (/^\d{10}$/.test(clean)) {
return clean.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#";
}
return clean + "#";
}
// todo - support international format
function _formatPhone(phone) {
if (!phone) return "";
const clean = String(phone).replace(/\s+/g, "");
if (/^\+33\d{9}$/.test(clean)) {
return clean.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5");
}
return clean;
}
// todo - escape html / link
function buildMeetingMessage(data) {
if (!data?.url) {
throw new Error("buildMeetingMessage: missing url in data");
}
const url = data.url;
const phone = _formatPhone(data.telephony?.phone_number);
const pin = _formatPin(data.telephony?.pin_code);
const telephonyBlock =
phone && pin
? `
Ou appelez (audio uniquement)
(FR) ${phone}
Code : ${pin}`
: "";
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion ${APP_NAME}
<a href="${url}">${url}</a>${telephonyBlock}
────────────────────────────────────────</pre>`;
return { url, message };
}
module.exports = { buildMeetingMessage };
+47
View File
@@ -0,0 +1,47 @@
const { pollSession } = require("./api");
const POLLING_INTERVAL_MS = 1000;
const POLLING_TIMEOUT_MS = 3 * 60 * 1000;
const POLLING_MAX_ATTEMPTS = POLLING_TIMEOUT_MS / POLLING_INTERVAL_MS;
function isPollAuthenticated(sessionData) {
return sessionData.state === "authenticated" && sessionData.access_token;
}
function startPolling(csrfToken, { onSuccess, onTimeout, onError }) {
let pollCount = 0;
let timeoutId = null;
let cancelled = false;
const poll = () => {
if (pollCount++ >= POLLING_MAX_ATTEMPTS) {
onTimeout?.();
return;
}
pollSession(csrfToken)
.then((sessionData) => {
if (cancelled) return;
if (isPollAuthenticated(sessionData)) {
onSuccess?.(sessionData);
return;
}
timeoutId = setTimeout(poll, POLLING_INTERVAL_MS);
})
.catch((err) => {
if (cancelled) return;
onError?.(err);
});
};
poll();
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
module.exports = {
startPolling,
};
+104
View File
@@ -0,0 +1,104 @@
const { isOfficeReady } = require("./helpers");
const SESSION_KEY = "meetSession";
// DEV NOTE:
// Office.context.roamingSettings persists data in the user's mailbox and
// synchronizes it via Exchange across all Outlook clients (desktop, web, mobile)
// where the user signs in. This means anything stored here (including tokens)
// leaves the local device boundary and is replicated across environments.
//
// Microsoft guidance explicitly advises NOT storing secrets (e.g., OAuth access
// tokens, refresh tokens, or other sensitive credentials) in roamingSettings,
// as it is not a secure storage mechanism and lacks OS-level protections.
//
// That said, for the current alpha version we accept this trade-off for simplicity,
// with the expectation that a more secure approach (e.g., in-memory tokens) will replace this.
function saveSession(data) {
if (!isOfficeReady()) {
return Promise.reject(new Error("Office not ready"));
}
if (!data || !data.access_token) {
return Promise.reject(new Error("Missing access_token"));
}
const expiresInSeconds = Number(data.expires_in);
const expiresAt =
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
: null;
const payload = JSON.stringify({
...data,
expiresAt,
savedAt: new Date().toISOString(),
});
return new Promise((resolve, reject) => {
const rs = Office.context.roamingSettings;
rs.set(SESSION_KEY, payload);
rs.saveAsync((result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(new Error(result.error?.message || "saveAsync failed"));
}
});
});
}
function loadSession() {
if (!isOfficeReady()) {
return null;
}
let session = null;
try {
const stored = Office.context.roamingSettings.get(SESSION_KEY);
if (stored) session = JSON.parse(stored);
} catch (e) {
clearSession();
return null;
}
if (!session) return null;
// Fail closed if expiry is missing — backend is expected to send expires_in.
if (!session.expiresAt) {
clearSession();
return null;
}
const expiresTs = Date.parse(session.expiresAt);
if (!Number.isFinite(expiresTs) || Date.now() >= expiresTs) {
clearSession();
return null;
}
return session;
}
function clearSession() {
if (!isOfficeReady()) {
return Promise.resolve();
}
return new Promise((resolve) => {
try {
const rs = Office.context.roamingSettings;
rs.remove(SESSION_KEY);
rs.saveAsync((result) => {
resolve();
});
} catch (e) {
resolve();
}
});
}
module.exports = {
saveSession,
loadSession,
clearSession,
};
@@ -0,0 +1,43 @@
const { URLS } = require("./urls");
const DIALOG_SIGNALS = {
ready: "ready",
done: "done",
};
const DIALOG_HEIGHT = 60;
const DIALOG_WIDTH = 50;
function openTransitDialog(transitToken, { onCancel, onError }) {
Office.context.ui.displayDialogAsync(
URLS.transitDialog,
{ height: DIALOG_HEIGHT, width: DIALOG_WIDTH, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
onError?.(asyncResult.error);
return;
}
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg) => {
if (arg.message === DIALOG_SIGNALS.ready) {
dialog.messageChild(transitToken);
return;
}
if (arg.message === DIALOG_SIGNALS.done) {
return;
}
onCancel?.();
dialog.close();
});
return dialog;
}
);
}
module.exports = {
openTransitDialog,
DIALOG_SIGNALS,
};
@@ -0,0 +1,18 @@
const TRANSIT_TOKEN_KEY = "transitToken";
function save(token) {
sessionStorage.setItem(TRANSIT_TOKEN_KEY, token);
}
function consume() {
try {
const token = sessionStorage.getItem(TRANSIT_TOKEN_KEY);
sessionStorage.removeItem(TRANSIT_TOKEN_KEY);
return token;
} catch (err) {
console.error("Failed to read transit token:", err);
return null;
}
}
module.exports = { save, consume };
+15
View File
@@ -0,0 +1,15 @@
const { BASE_URL } = require("./index");
const ADDONS_BASE_URL = `${BASE_URL}/api/v1.0/addons/sessions`;
const URLS = {
authenticate: `${BASE_URL}/api/v1.0/authenticate/`,
successPage: `${BASE_URL}/addons/outlook/success.html`,
transitDialog: `${BASE_URL}/addons/outlook/transit.html`,
init: `${ADDONS_BASE_URL}/init/`,
poll: `${ADDONS_BASE_URL}/poll/`,
exchange: `${ADDONS_BASE_URL}/exchange/`,
rooms: `${BASE_URL}/external-api/v1.0/rooms/`,
};
module.exports = { URLS };
+81
View File
@@ -0,0 +1,81 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#sideload-msg {
display: none;
}
#status {
display: none;
}
.spinner-container {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
}
.spinner-svg {
width: 56px;
height: 56px;
}
/* Background arc (light gray ring) */
.spinner-track {
stroke: #E5E7EB; /* primary.100 equivalent */
fill: none;
stroke-width: 3;
stroke-linecap: round;
}
/* Foreground rotating arc */
.spinner-arc {
stroke: #000091; /* primary.800 equivalent */
fill: none;
stroke-width: 3;
stroke-linecap: round;
/* circumference = 2 * PI * r where r = 11 -> ~69.115 */
/* show 30% -> dashoffset = c - 0.3 * c = ~48.38 */
stroke-dasharray: 69.115 69.115;
stroke-dashoffset: 48.38;
transform-origin: center;
animation: spinner-rotate 1s ease-in-out infinite;
}
@keyframes spinner-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Hourglass fallback for reduced motion */
.spinner-fallback {
display: none;
color: #000091;
}
@media (prefers-reduced-motion: reduce) {
.spinner-svg {
display: none;
}
.spinner-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div class="spinner-container"
role="progressbar"
aria-label="Chargement..."
>
<svg class="spinner-svg"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background track -->
<circle class="spinner-track" cx="14" cy="14" r="11"
/>
<!-- Rotating arc -->
<circle class="spinner-arc" cx="14" cy="14" r="11"
/>
</svg>
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
<span class="spinner-fallback" aria-hidden="true">
<svg width="22"
height="22"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style="display: block; transform: translateY(1px);"
>
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
</svg>
</span>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
const { applyAppName } = require("../common/helpers");
const { exchangeSession } = require("../common/api");
const { consume } = require("../common/transitToken");
applyAppName();
const transitToken = consume();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
window.close();
});
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-app-name></title>
<link rel="stylesheet" href="taskpane.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text">Chargement...</p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button>
<p>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
<button id="btn-disconnect">Se déconnecter</button>
</div>
</div>
</div>
<footer id="version-tag">
<span class="version-badge">alpha</span>
<span class="version-number">0.0.1</span>
</footer>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
const { APP_NAME } = require("../common");
const { applyAppName } = require("../common/helpers");
const { initSession, createRoom } = require("../common/api");
const { startPolling } = require("../common/polling");
const { openTransitDialog } = require("../common/transitDialog");
const { loadSession, saveSession, clearSession } = require("../common/session");
const { buildMeetingMessage } = require("../common/messageBuilder");
// todo - support loading view while polling
// todo - support error view
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
}
function connect() {
initSession()
.then((data) => {
const stopPolling = startPolling(data.csrf_token, {
onSuccess: (sessionData) => {
saveSession(sessionData).then(() => showView("auth"));
},
onTimeout: () => {
showView("unauth");
},
onError: (err) => {
console.error(err);
},
});
openTransitDialog(data.transit_token, {
onCancel: () => stopPolling(),
onError: (err) => {
stopPolling();
},
});
})
.catch((err) => {
console.error(err);
});
}
function disconnect() {
clearSession().finally(() => showView("unauth"));
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
}
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
console.error("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
_setButtonLoading();
createRoom(session)
.then((data) => {
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
return new Promise((resolve, reject) => {
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(getResult.error);
return;
}
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
}
);
});
});
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setButtonIdle();
});
}
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
applyAppName();
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
showView("auth");
} else {
showView("unauth");
}
}
});
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div
class="spinner-container"
role="progressbar"
aria-label="Chargement..."
>
<svg
class="spinner-svg"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background track -->
<circle class="spinner-track" cx="14" cy="14" r="11"
/>
<!-- Rotating arc -->
<circle class="spinner-arc" cx="14" cy="14" r="11"
/>
</svg>
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
<span class="spinner-fallback" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style="display: block; transform: translateY(1px);"
>
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
</svg>
</span>
</div>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
const { applyAppName } = require("../common/helpers");
const { URLS } = require("../common/urls");
const { save } = require("../common/transitToken");
const { DIALOG_SIGNALS } = require("../common/transitDialog");
// Initiate the authentication flow, then return to the success page
function getAuthenticateUrl() {
const url = new URL(URLS.authenticate);
url.searchParams.set("returnTo", URLS.successPage);
return url.toString();
}
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
Office.context.ui.addHandlerAsync(
Office.EventType.DialogParentMessageReceived,
function (arg) {
const transitToken = arg.message;
if (typeof transitToken !== "string" || transitToken.trim() === "") {
console.error("Invalid transit token received from parent dialog.");
return;
}
// Runs inside the dialog window.
// Flow:
// transit.html saves token → navigates to /authenticate → OAuth redirect →
// success.html. sessionStorage survives because it's per-window-per-origin
// and the dialog window persists across same-origin navigations.
// Fragile: if the IdP opens the redirect in a new tab/window, this breaks
// silently.
// An alternative could be to pass the token via the OAuth `state` param
// and read it back from the redirect URL.
try {
save(transitToken);
Office.context.ui.messageParent(DIALOG_SIGNALS.done);
window.location.href = getAuthenticateUrl();
} catch (err) {
console.error("Failed to store transit token:", err);
}
},
function (result) {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("Failed to register DialogParentMessageReceived handler.", result.error);
return;
}
Office.context.ui.messageParent(DIALOG_SIGNALS.ready);
}
);
});
+128
View File
@@ -0,0 +1,128 @@
/* eslint-disable no-undef */
const devCerts = require("office-addin-dev-certs");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
}
module.exports = async (env, options) => {
const config = {
devtool: "source-map",
entry: {
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
commands: "./src/commands/commands.js",
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
success: ["./src/success/success.js", "./src/success/success.html"],
},
output: {
clean: true,
},
resolve: {
extensions: [".html", ".js"],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.html$/,
exclude: /node_modules/,
use: {
loader: "html-loader",
options: {
sources: {
urlFilter: (attribute, value) => {
// Don't try to resolve the runtime-injected config
if (value.includes("config.js")) {
return false;
}
return true;
},
},
},
},
},
{
test: /\.(png|jpg|jpeg|gif|ico)$/,
type: "asset/resource",
generator: {
filename: "assets/[name][ext][query]",
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: "taskpane.html",
template: "./src/taskpane/taskpane.html",
chunks: ["polyfill", "taskpane"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new CopyWebpackPlugin({
patterns: [
{
from: "assets/*",
to: "assets/[name][ext][query]",
}
],
}),
new HtmlWebpackPlugin({
filename: "commands.html",
template: "./src/commands/commands.html",
chunks: ["polyfill", "commands"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new HtmlWebpackPlugin({
filename: "transit.html",
template: "./src/transit/transit.html",
chunks: ["polyfill", "transit"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new HtmlWebpackPlugin({
filename: "success.html",
template: "./src/success/success.html",
chunks: ["polyfill", "success"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new htmlWebpackInjectAttributesPlugin(),
],
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
},
server: {
type: "https",
options:
env.WEBPACK_BUILD || options.https !== undefined
? options.https
: await getHttpsOptions(),
},
port: process.env.npm_package_config_dev_server_port || 3000,
},
};
return config;
};
+38
View File
@@ -0,0 +1,38 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
**/.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.iml
db.sqlite3
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Env
.env
+50 -13
View File
@@ -1,35 +1,72 @@
FROM python:3.13-slim AS base
FROM python:3.13.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml .
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
RUN mkdir /install && \
pip install --prefix=/install .
WORKDIR /app
# Install production dependencies without the project itself (cacheable layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- Development image ----
FROM base AS development
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-extras
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production
WORKDIR /app
# Copy the pre-built virtualenv and application source
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# 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}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
CMD ["python", "multi_user_transcriber.py", "start"]
+5
View File
@@ -0,0 +1,5 @@
"""Storage parsers specific exceptions."""
class MissingConfigError(Exception):
"""Raised when a variable is not set in configuration."""
+382
View File
@@ -0,0 +1,382 @@
"""Metadata agent that extracts metadata from active room."""
import asyncio
import json
import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentServer,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
load_dotenv()
logger = logging.getLogger("metadata-collector")
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
server = AgentServer(
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
server.setup_fnc = prewarm
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
participant_id: str
type: str
timestamp: datetime
data: Optional[str] = None
def serialize(self) -> dict:
"""Return a JSON-serializable dictionary representation of the event."""
data = asdict(self)
data["timestamp"] = self.timestamp.isoformat()
return data
class VADAgent(Agent):
"""Agent that monitors voice activity for a specific participant."""
def __init__(self, participant_identity: str, events: List):
"""Initialize with a participant identity and shared events list."""
super().__init__(
instructions="not-needed",
)
self.participant_identity = participant_identity
self.events = events
async def on_enter(self) -> None:
"""Initialize VAD monitoring for this participant."""
@self.session.on("user_state_changed")
def on_user_state(event):
timestamp = datetime.now(timezone.utc)
if event.new_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_start",
timestamp=timestamp,
)
self.events.append(event)
elif event.old_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_end",
timestamp=timestamp,
)
self.events.append(event)
class MetadataCollector:
"""Collect meeting events across all participants in a room.
Creates one AgentSession per participant to capture VAD events
(speech start/end), and listens for connection, disconnection,
and chat events. Persists all collected events as JSON to S3
on shutdown.
"""
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataCollector initialized")
def start(self):
"""Start listening for room-level events."""
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
logger.info("Started listening for participant events")
async def on_chat_message_received(
self, reader: rtc.TextStreamReader, participant_identity: str
):
"""Read a complete chat message and record it as an event."""
full_text = await reader.read_all()
logger.info("Received chat message from %s", participant_identity)
self.events.append(
MetadataEvent(
participant_id=participant_identity,
type="chat_received",
timestamp=datetime.now(timezone.utc),
data=full_text,
)
)
def handle_chat_stream(self, reader, participant_identity):
"""Schedule async processing of an incoming chat stream."""
task = asyncio.create_task(
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_events],
"participants": participants,
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
async def aclose(self):
"""Close all sessions and cleanup resources."""
logger.info("Closing all VAD monitoring sessions…")
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()],
return_exceptions=True,
)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
logger.info("All VAD sessions closed")
self.save()
async def on_participant_entrypoint(
self, ctx: JobContext, participant: rtc.RemoteParticipant
):
"""Handle new participant by starting a VAD monitoring session."""
if participant.identity in self._sessions:
logger.debug("Session already exists for %s", participant.identity)
return
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_connected",
timestamp=datetime.now(timezone.utc),
)
)
self.participants[participant.identity] = participant.name
logger.info("New participant connected: %s", participant.identity)
try:
session = await self._start_session(participant)
self._sessions[participant.identity] = session
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing VAD monitoring."""
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_disconnected",
timestamp=datetime.now(timezone.utc),
)
)
session = self._sessions.pop(participant.identity, None)
if session is None:
logger.debug("No session found for %s", participant.identity)
return
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
logger.info("Participant's name changed: %s", participant.identity)
self.participants[participant.identity] = participant.name
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start VAD monitoring session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
# Create session with VAD only - no STT, LLM, or TTS
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
turn_detection="vad",
user_away_timeout=30.0,
)
# Set up room IO to receive audio from this specific participant
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
audio_enabled=True,
text_enabled=False,
),
output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=False,
),
)
await room_io.start()
await session.start(
agent=VADAgent(
participant_identity=participant.identity, events=self.events
)
)
return session
async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session."""
try:
await session.drain()
await session.aclose()
except Exception:
logger.exception("Error closing session")
async def handle_job_request(job_req: JobRequest) -> None:
"""Accept or reject the job request based on agent presence in the room."""
room_name = job_req.room.name
recording_id = job_req.job.metadata
agent_identity = f"{AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lk:
try:
resp = await lk.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
already_present = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == agent_identity
for p in resp.participants
)
if already_present:
logger.info("Agent already in the room '%s' — reject", room_name)
await job_req.reject()
else:
logger.info(
"Accept job for '%s' — identity=%s", room_name, agent_identity
)
await job_req.accept(identity=agent_identity, metadata=recording_id)
except Exception:
logger.exception("Error treating the job for '%s'", room_name)
await job_req.reject()
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
metadata_collector.start()
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
if __name__ == "__main__":
cli.run_app(server)
+10 -10
View File
@@ -1,25 +1,25 @@
[project]
name = "agents"
version = "1.10.0"
version = "1.16.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-agents==1.4.5",
"livekit-plugins-deepgram==1.4.5",
"livekit-plugins-silero==1.4.5",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1",
"protobuf==6.33.5"
"python-dotenv==1.2.2",
"protobuf==6.33.5",
"minio==7.2.15"
]
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"ruff==0.15.6",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.uv]
package = false
[tool.ruff]
target-version = "py313"
+1963
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Meet core add-ons module."""
+344
View File
@@ -0,0 +1,344 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from core.models import User
from core.services.jwt_token import JwtTokenService
logger = getLogger(__name__)
_PUBLIC_SESSION_FIELDS = frozenset(
{"state", "access_token", "token_type", "expires_in", "scope"}
)
class SessionDataError(Exception):
"""Raised when session data is invalid or malformed."""
class CSRFTokenError(Exception):
"""Raised when CSRF token verification fails."""
class TransitTokenError(Exception):
"""Raised when a transit token is invalid or expired."""
class SessionExpiredError(Exception):
"""Raised when a session has expired."""
class SessionNotFoundError(Exception):
"""Raised when a session is not found."""
class SuspiciousSessionError(Exception):
"""Raised when session state indicates a possible attack or bug."""
class SessionState(str, Enum):
"""Add-on authentication session lifecycle states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TransitTokenState(str, Enum):
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
PENDING = "pending"
CONSUMED = "consumed"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Build the underlying JWT service and validate required settings."""
if not settings.ADDONS_CSRF_SECRET:
raise ImproperlyConfigured("CSRF Secret is required.")
if not settings.ADDONS_TOKEN_SCOPE:
raise ImproperlyConfigured("Token scope must be defined.")
self._token_service = JwtTokenService(
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
algorithm=settings.ADDONS_TOKEN_ALG,
issuer=settings.ADDONS_TOKEN_ISSUER,
audience=settings.ADDONS_TOKEN_AUDIENCE,
expiration_seconds=settings.ADDONS_TOKEN_TTL,
token_type=settings.ADDONS_TOKEN_TYPE,
)
@staticmethod
def _cache_key(prefix: str, token: str) -> str:
"""Build a namespaced cache key: ``addons_{prefix}_{token}``."""
return f"addons_{prefix}_{token}"
@staticmethod
def _derive_csrf_token(session_id: str) -> str:
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET."""
return hmac.new(
settings.ADDONS_CSRF_SECRET.encode("utf-8"),
session_id.encode("utf-8"),
hashlib.sha256,
).hexdigest()
@staticmethod
def _validate_session_not_expired(session_data: dict) -> int:
"""Return remaining seconds until expiry, or raise if missing/malformed/expired."""
expires_at_str = session_data.get("expires_at")
if expires_at_str is None:
raise SessionDataError("Invalid session data: missing expiration.")
try:
expires_at = datetime.fromisoformat(expires_at_str)
except ValueError as e:
raise SessionDataError("Invalid session data: malformed expiration.") from e
remaining_seconds = int(
(expires_at - datetime.now(timezone.utc)).total_seconds()
)
if remaining_seconds <= 0:
raise SessionExpiredError("Session expired.")
return remaining_seconds
def _generate_session_id(self) -> str:
"""Generate a high-entropy URL-safe session_id."""
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
def _generate_transit_token(self) -> str:
"""Generate a high-entropy URL-safe transit token."""
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
def init_session(self) -> tuple[str, str, str]:
"""Create a new pending session and its transit binding.
Returns:
(transit_token, session_id, csrf_token)
"""
session_id = self._generate_session_id()
transit_token = self._generate_transit_token()
csrf_token = self._derive_csrf_token(session_id)
expires_at = (
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL)
).isoformat()
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at,
"transit_token": transit_token,
}
cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
session_data,
settings.ADDONS_SESSION_TTL,
)
transit_token_data = {
"session_id": session_id,
"state": TransitTokenState.PENDING,
}
cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token),
transit_token_data,
settings.ADDONS_TRANSIT_TOKEN_TTL,
)
return transit_token, session_id, csrf_token
def verify_csrf(self, session_id: str, submitted_csrf: str) -> None:
"""Constant-time verify submitted_csrf against HMAC(session_id). Raise on mismatch."""
expected_csrf = self._derive_csrf_token(session_id)
if not hmac.compare_digest(expected_csrf, submitted_csrf):
raise CSRFTokenError("Invalid CSRF token.")
def consume_transit_token(self, transit_token: str) -> str:
"""Mark transit token consumed and return its session_id.
A replay (second consume) evicts the session as a security cleanup and raises.
Raises:
TransitTokenError: If token is unknown, expired, or already consumed.
"""
cache_key = self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
transit_token_data = cache.get(cache_key)
if transit_token_data is None:
# Indistinguishable from here: either the token was never issued (attacker
# probing or client bug) or it was issued but expired before consumption.
logger.warning(
"Transit token not found in cache (unknown or expired).",
)
raise TransitTokenError("Invalid or expired transit token.")
state = transit_token_data.get("state", None)
session_id = transit_token_data.get("session_id", None)
if not session_id:
logger.warning("Transit token data missing session_id.")
raise TransitTokenError("Invalid transit token.")
if state == TransitTokenState.CONSUMED:
logger.warning(
"Replay on session %s",
session_id,
)
# Security cleanup: a replay attempt means the transit token leaked
# (or an attacker is probing). Evict the session so the authenticated
# tokens — if they exist — can no longer be polled.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise TransitTokenError("Transit token already consumed.")
new_transit_token_data = {
"state": TransitTokenState.CONSUMED,
"session_id": session_id,
}
cache.set(
cache_key,
new_transit_token_data,
settings.ADDONS_SESSION_TTL,
)
return session_id
@staticmethod
def is_session_pending(session_data: dict) -> bool:
"""Return True if the public session dict is still in the pending state."""
return session_data.get("state") == SessionState.PENDING
def _get_session_data(self, session_id: str) -> dict:
"""Fetch raw session data from cache, or raise SessionNotFoundError."""
if not session_id:
raise SessionNotFoundError("Session not found.")
data = cache.get(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
if data is None:
raise SessionNotFoundError("Session not found.")
return data
def get_session(self, session_id: str) -> dict:
"""Return the public session view; evict the session on authenticated read.
Raises:
SessionNotFoundError: If session is not found.
SessionDataError: If session data is missing the state field.
"""
# raises if session is not found
session_data = self._get_session_data(session_id)
if "state" not in session_data:
raise SessionDataError("Invalid session data: missing state field.")
# One-time read: clear both bindings for authenticated sessions
if session_data["state"] == SessionState.AUTHENTICATED:
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
# Return public fields only
return {k: v for k, v in session_data.items() if k in _PUBLIC_SESSION_FIELDS}
def _validate_transit_token_state(self, session_data: dict) -> None:
"""Assert the session's transit token exists in cache and is in CONSUMED state.
Raises:
SessionDataError: session_data is missing the transit_token field.
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
"""
transit_token = session_data.get("transit_token", None)
if transit_token is None:
raise SessionDataError("Invalid session data: missing transit_token field.")
transit_token_data = cache.get(
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
)
if transit_token_data is None:
logger.warning("Transit token missing when setting access token.")
raise SuspiciousSessionError("Transit token not found.")
if transit_token_data.get("state") != TransitTokenState.CONSUMED:
logger.warning("Access token requested without completing transit flow.")
raise SuspiciousSessionError("Transit token not consumed.")
def set_access_token(self, user: User, session_id: str) -> None:
"""Authenticate a pending session by minting a JWT and storing it on the session.
Non-pending sessions are evicted as a security cleanup before raising.
Raises:
SessionNotFoundError: If session doesn't exist.
SessionDataError: If session data is malformed.
SessionExpiredError: If session has expired.
SuspiciousSessionError: If session is not pending or transit wasn't consumed.
"""
# raises if session is not found
session_data = self._get_session_data(session_id)
if session_data.get("state") != SessionState.PENDING:
logger.warning(
"Session's state is not pending. Suspicious.",
)
# Security cleanup: evict the session so any cached tokens cannot be polled.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise SuspiciousSessionError("Session is not in pending state.")
# raises if transit_token is invalid
try:
self._validate_transit_token_state(session_data)
except SuspiciousSessionError:
# Security cleanup: evict the session.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise
# raises if session is expired
remaining_seconds = self._validate_session_not_expired(session_data)
response = self._token_service.generate_jwt(user, settings.ADDONS_TOKEN_SCOPE)
new_data = {
"access_token": response["access_token"],
"token_type": response["token_type"],
"expires_in": response["expires_in"],
"scope": response["scope"],
"expires_at": session_data["expires_at"],
"state": SessionState.AUTHENTICATED,
}
cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
new_data,
remaining_seconds,
)
+229
View File
@@ -0,0 +1,229 @@
"""Add-ons API endpoints"""
from logging import getLogger
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from rest_framework import decorators, viewsets
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from core.addons.service import (
CSRFTokenError,
SessionDataError,
SessionExpiredError,
SessionNotFoundError,
SuspiciousSessionError,
TokenExchangeService,
TransitTokenError,
)
from core.api.feature_flag import FeatureFlag
from core.api.permissions import IsAuthenticated
logger = getLogger(__name__)
class SessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange.
Implements a three-step flow that lets a third-party add-on (running in an
embedded iframe) obtain an access token without exposing it to client-side
JavaScript:
1. /init: the add-on opens a session and receives a short-lived transit
token (used to bootstrap the OAuth-style exchange in a dialog) and a
CSRF token. The opaque session id is stored in an HttpOnly, Secure,
SameSite=None cookie so it can accompany cross-origin polls.
2. /poll: the add-on polls until the session transitions from pending to
authenticated. On the terminal read, the session payload (access
token, token type, expiry, etc.) is returned, the session is evicted
server-side, and the session cookie is cleared so the tokens can be
retrieved exactly once.
3. /exchange: called from the post-login callback page on our own domain,
after the user has authenticated in a dialog opened by the addon. The
transit token (carried client-side via postMessage + sessionStorage)
is redeemed here for the authenticated user's access token, which is
stored server-side against the session. Requires an authenticated
user — that user is whose access token gets bound to the session.
/init and /poll authenticate the caller through the session cookie +
CSRF token pair alone — no user login is required, since the whole point
of the flow is to bootstrap one. /exchange, by contrast, requires an
authenticated user and does not use the addonsSid cookie.
"""
throttle_classes = []
@decorators.action(
detail=False,
methods=["POST"],
url_path="init",
authentication_classes=[],
permission_classes=[],
)
@FeatureFlag.require("addons")
def init(self, request):
"""Open a new add-on authentication session.
Creates a fresh session server-side and returns the credentials the
add-on needs to drive the rest of the flow.
"""
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
response = drf_response.Response(
{"transit_token": transit_token, "csrf_token": csrf_token},
status=drf_status.HTTP_201_CREATED,
)
# SameSite=None allows the cookie to be sent on cross-origin requests,
# which is required because the /poll endpoint is called from an iframe
# embedded in a third-party site. Secure=True is mandatory when SameSite=None.
# HttpOnly prevents JS access, so the cookie can only be read by the server.
response.set_cookie(
key=settings.ADDONS_SESSION_ID_COOKIE,
value=session_id,
max_age=settings.ADDONS_SESSION_TTL,
httponly=True,
secure=True,
samesite="None",
)
return response
@decorators.action(
detail=False,
methods=["POST"],
url_path="poll",
authentication_classes=[],
permission_classes=[],
)
@FeatureFlag.require("addons")
def poll(self, request):
"""Poll a session for its current state and, if terminal, consume it.
Authenticates the caller using the addonsSid cookie (set by
/init) together with the X-CSRFToken header, which must match
the CSRF token issued for that session. The session id alone is not
sufficient — both must be presented and must correspond.
Behavior depends on the session's current state:
- **Pending**: the token exchange has not yet completed. Returns
202 Accepted with `{"state": "pending"}`. The cookie is preserved
so the add-on can keep polling.
- **Authenticated** (or any other terminal state): returns 200 OK
with the session payload (access token, token type, expiry, etc.)
and clears the `addonsSid` cookie. The session is also evicted
server-side on this terminal read, so the tokens can be retrieved
exactly once.
A CSRF mismatch is treated as a `SuspiciousOperation` rather than a
normal 4xx, so it is logged by Django's security middleware and
surfaced as a 400 without leaking which check failed.
"""
session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE)
submitted_csrf = request.headers.get("X-CSRFToken")
if not session_id:
return drf_response.Response(
{"detail": "Missing credentials."},
status=drf_status.HTTP_401_UNAUTHORIZED,
)
if not submitted_csrf:
return drf_response.Response(
{"detail": "Missing CSRF token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
service = TokenExchangeService()
try:
service.verify_csrf(session_id, submitted_csrf)
except CSRFTokenError as e:
raise SuspiciousOperation(str(e)) from e
try:
session = service.get_session(session_id)
except SessionNotFoundError:
return drf_response.Response(
{"detail": "Session not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
except SessionDataError:
return drf_response.Response(
{"detail": "Invalid or expired session."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
if service.is_session_pending(session):
return drf_response.Response(
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
)
response = drf_response.Response(session, status=drf_status.HTTP_200_OK)
response.delete_cookie(
key=settings.ADDONS_SESSION_ID_COOKIE,
samesite="None",
)
return response
@decorators.action(
detail=False,
methods=["POST"],
url_path="exchange",
permission_classes=[IsAuthenticated],
)
@FeatureFlag.require("addons")
def exchange(self, request):
"""Redeem a transit token for an access token bound to the current user.
Called from the post-OIDC callback page on our own domain. The transit
token was issued by /init, passed to the authentication dialog via
postMessage, stashed in sessionStorage, and read back by this page
after login completes.
The authenticated user (request.user) is whose access token gets stored
against the session. On success, the addon's next /poll will transition
from pending to authenticated and receive the token payload.
Transit tokens are single-use: a replayed token is rejected with 400.
"""
transit_token = request.data.get("transit_token")
if not transit_token:
return drf_response.Response(
{"detail": "Missing transit_token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
service = TokenExchangeService()
try:
session_id = service.consume_transit_token(transit_token)
except TransitTokenError:
return drf_response.Response(
{"detail": "Invalid or expired transit token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
try:
service.set_access_token(request.user, session_id)
except SessionNotFoundError:
return drf_response.Response(
{"detail": "Session not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
except (SessionDataError, SessionExpiredError, SuspiciousSessionError):
return drf_response.Response(
{"detail": "Invalid or expired session."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)
+35 -2
View File
@@ -197,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."""
@@ -224,7 +256,7 @@ class RecordingAdmin(admin.ModelAdmin):
"updated_at",
"worker_id",
)
actions = [resend_notification]
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."""
@@ -276,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",
@@ -285,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
+3
View File
@@ -13,6 +13,9 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
}
@classmethod
+38
View File
@@ -1,5 +1,6 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404
from rest_framework import permissions
@@ -116,6 +117,13 @@ class FilePermission(IsAuthenticated):
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
@@ -128,3 +136,33 @@ class FilePermission(IsAuthenticated):
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
"""
Grant muting rights based on role or room configuration.
- Admins and owners can always mute.
- When `everyone_can_mute` is enabled on the room, any participant
currently in the room (proven by a valid LiveKit token for that room)
can mute.
"""
def has_object_permission(self, request, view, obj):
"""Check if the requesting user is allowed to mute a participant in the given room."""
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
# Always allow admins/owners when authenticated with session cookie
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
return True
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
if not everyone_can_mute:
return False
if not is_livekit_token_auth:
return False
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
+48 -18
View File
@@ -13,7 +13,8 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -131,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer):
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value):
"""Validate room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except PydanticValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -155,11 +166,6 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
@@ -176,7 +182,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -232,11 +238,14 @@ class RecordingOptions(BaseModel):
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.
collect_metadata: Whether to collect additional metadata during recording.
When `None`, no metadata are collected.
"""
language: str | None = None
transcribe: bool | None = None
collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
@@ -303,6 +312,21 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
class RoomConfiguration(BaseModel):
"""Validate room configuration structure.
Unknown fields are rejected.
"""
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"}
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
@@ -313,9 +337,7 @@ class ParticipantPermission(BaseModel):
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
can_publish_sources: list[TrackSource] = Field(default_factory=list)
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
@@ -324,6 +346,10 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"}
@field_serializer("can_publish_sources")
def _serialize_sources(self, sources: list[str]) -> list[str]:
return [s.upper() for s in sources]
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -366,14 +392,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
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
@@ -533,3 +551,15 @@ class CreateFileSerializer(ListFileSerializer):
def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.")
class RaiseHandSerializer(BaseValidationOnlySerializer):
"""Serializer for raising or lowering a participant's hand in a room."""
raised = serializers.BooleanField()
class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room."""
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
+243 -30
View File
@@ -1,17 +1,20 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import re
import uuid
from logging import getLogger
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as django_filters
from rest_framework import (
@@ -30,18 +33,25 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.settings import api_settings
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,
)
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -62,10 +72,16 @@ from core.services.lobby import (
LobbyService,
)
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
@@ -78,17 +94,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
FILE_FOLDER = settings.FILE_UPLOAD_PATH
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[\d\w]+"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{FILE_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/\.{FILE_EXT_REGEX:s})$"
)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
@@ -300,6 +305,41 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
room = serializer.save()
if (
room.configuration == old_configuration
and room.access_level == old_access_level
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -323,16 +363,27 @@ class RoomViewSet(
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.model_dump(exclude_none=True) if options else {},
)
try:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
# IntegrityError covers the race where two concurrent requests both
# pass that check and the DB-level UNIQUE constraint catches the loser.
return drf_response.Response(
{"error": f"A recording is already in progress for room {room.slug}"},
status=drf_status.HTTP_409_CONFLICT,
)
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
@@ -340,11 +391,23 @@ class RoomViewSet(
try:
worker_manager.start(recording)
except RecordingStartError:
models.Recording.objects.filter(pk=recording.pk).update(
status=models.RecordingStatusChoices.FAILED_TO_START
)
return drf_response.Response(
{"error": f"Recording failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
status=drf_status.HTTP_502_BAD_GATEWAY,
)
if settings.METADATA_COLLECTOR_ENABLED and (
recording.options.get("collect_metadata", False)
):
try:
MetadataCollectorService().start(recording)
logger.debug("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
return drf_response.Response(
{"message": f"Recording successfully started for room {room.slug}"},
status=drf_status.HTTP_201_CREATED,
@@ -497,9 +560,7 @@ 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,
@@ -594,7 +655,11 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
@@ -603,12 +668,37 @@ class RoomViewSet(
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
# joined. Cross-check identity against the live participant list until auth
# is hardened. Skipped for non-LiveKit auth backends.
caller_identity = getattr(request.auth, "identity", None)
if caller_identity is not None:
try:
ParticipantsManagement().check_if_in_meeting(
room_name=str(room.pk),
identity=caller_identity,
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Failed to verify caller presence for mute in room %s; denying",
room.pk,
)
return drf_response.Response(
{"error": "Could not verify caller presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"],
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to mute participant"},
@@ -647,6 +737,11 @@ class RoomViewSet(
permission=permission.model_dump() if permission else None,
name=serializer.validated_data.get("name"),
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant"},
@@ -679,6 +774,11 @@ class RoomViewSet(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to remove participant"},
@@ -689,6 +789,92 @@ class RoomViewSet(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="toggle-hand",
url_name="toggle-hand",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
"""Raise or lower the current participant's hand in the room."""
room = self.get_object()
serializer = serializers.RaiseHandSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
# means the hand is raised. The timestamp is used by clients to determine
# the order in which participants raised their hands.
hand_raised_at = (
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
attributes={"handRaisedAt": hand_raised_at},
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant hand state"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="rename",
url_name="rename",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def rename(self, request, pk=None): # pylint: disable=unused-argument
"""Rename the current participant in the room."""
room = self.get_object()
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
name=serializer.validated_data["name"],
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to rename participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,
@@ -766,14 +952,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:
@@ -973,6 +1164,26 @@ class FileViewSet(
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):
@@ -980,6 +1191,7 @@ class FileViewSet(
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.
@@ -1162,6 +1374,7 @@ class FileViewSet(
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
+11 -2
View File
@@ -14,10 +14,19 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request):
token = request.data.get("token")
if not token:
auth_header = request.headers.get("Authorization")
if not auth_header:
return None # No authentication attempted
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>"
)
token = parts[1]
try:
verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
+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.
@@ -23,7 +23,14 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
def __init__(
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
self,
secret_key,
algorithm,
issuer,
audience,
expiration_seconds,
token_type,
is_enabled,
):
"""Initialize the JWT authentication backend with the given token service configuration.
@@ -34,10 +41,17 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
is_enabled: Whether this authentication backend is active
"""
super().__init__()
self.is_enabled = is_enabled
self._token_service = None
if not self.is_enabled:
return
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
@@ -54,6 +68,9 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Tuple of (user, payload) if authentication successful, None otherwise
"""
if not self.is_enabled:
return None
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
@@ -186,6 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
is_enabled=settings.APPLICATION_ENABLED,
)
def validate_payload(self, payload):
@@ -203,7 +221,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
if not application.is_active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
@@ -214,6 +232,26 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
raise exceptions.AuthenticationFailed("Invalid token type.")
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
Validates JWT tokens issued to addons.
"""
def __init__(self):
"""Initialize authentication backend with addons JWT settings from Django settings."""
super().__init__(
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
algorithm=settings.ADDONS_TOKEN_ALG,
issuer=settings.ADDONS_TOKEN_ISSUER,
audience=settings.ADDONS_TOKEN_AUDIENCE,
expiration_seconds=settings.ADDONS_TOKEN_TTL,
token_type=settings.ADDONS_TOKEN_TYPE,
is_enabled=settings.ADDONS_ENABLED,
)
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
+6 -3
View File
@@ -20,6 +20,7 @@ from rest_framework import (
)
from core import api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from . import authentication, permissions, serializers
@@ -36,6 +37,7 @@ class ApplicationViewSet(viewsets.ViewSet):
url_path="token",
url_name="token",
)
@FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs):
"""Generate JWT access token for application delegation.
@@ -61,12 +63,12 @@ class ApplicationViewSet(viewsets.ViewSet):
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)
@@ -173,6 +175,7 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
+1 -1
View File
@@ -129,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 = []
@@ -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',
),
]
+3 -2
View File
@@ -388,6 +388,7 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Public configuration exposed to any room participant via the API
configuration = models.JSONField(
blank=True,
default=dict,
@@ -759,7 +760,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
)
@@ -952,7 +953,7 @@ class File(BaseModel):
_, 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}"
return f"{self.key_base}{extension!s}"
def get_abilities(self, user):
"""
@@ -1,7 +1,9 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import logging
import smtplib
from datetime import datetime, timezone
from django.conf import settings
from django.core.mail import send_mail
@@ -9,9 +11,12 @@ from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models
from core import models, utils
logger = logging.getLogger(__name__)
@@ -131,7 +136,50 @@ class NotificationService:
return not has_failures
@staticmethod
def _notify_summary_service(recording):
async def _get_recording_timestamps(worker_id):
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
FileInfo.started_at is more accurate than EgressInfo.started_at because
it reflects when file recording actually began. The started_at value exposed
in the manifest file, as well as in the EgressInfo returned by the API,
corresponds to when the egress service received the request, not the moment
the egress worker effectively joined the room.
Returns:
Tuple of (started_at, ended_at) datetimes, either may be None.
"""
if not worker_id:
return None, None
custom_configuration = {
**settings.LIVEKIT_CONFIGURATION,
"timeout": aiohttp.ClientTimeout(total=10),
}
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
try:
egress_list = await lkapi.egress.list_egress(
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
)
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
logger.exception("Could not fetch egress info for worker %s", worker_id)
return None, None
finally:
await lkapi.aclose()
if not egress_list.items or not egress_list.items[0].file_results:
logger.debug("No file_results for worker %s", worker_id)
return None, None
file_result = egress_list.items[0].file_results[0]
def _ns_to_utc(ns):
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _notify_summary_service(recording: models.Recording):
"""Notify summary service about a new recording."""
if (
@@ -150,24 +198,35 @@ class NotificationService:
.first()
)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
else:
metadata_filename = None
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
payload = {
"owner_id": str(owner_access.user.id),
"filename": recording.key,
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"owner_timezone": str(owner_access.user.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
}
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
@@ -0,0 +1,91 @@
"""Meeting metadata collection service."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync, sync_to_async
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
from core.models import Recording
logger = getLogger(__name__)
class MetadataCollectorException(Exception):
"""Generic exception in the metadata collector."""
class MetadataCollectorService:
"""Service for dispatching and managing the metadata collector agent."""
@async_to_sync
async def start(self, recording: Recording):
"""Explicitly dispatch the metadata collector agent to a room."""
lkapi = utils.create_livekit_client()
room_id = str(recording.room.id)
try:
response = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.METADATA_COLLECTOR_AGENT_NAME,
room=room_id,
metadata=str(recording.id),
)
)
except Exception as e:
logger.exception(
"Failed to create metadata collector agent for room %s", room_id
)
raise MetadataCollectorException(
"Failed to create metadata collector agent"
) from e
finally:
await lkapi.aclose()
dispatch_id = getattr(response, "id", None)
if not dispatch_id:
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
raise MetadataCollectorException(
f"LiveKit did not return a dispatch_id for room {room_id}"
)
recording.options["metadata_collector_dispatch_id"] = dispatch_id
await sync_to_async(recording.save)(update_fields=["options"])
return dispatch_id
@async_to_sync
async def stop(self, recording: Recording):
"""Stop and delete the agent dispatch associated to the room."""
room_id = str(recording.room.id)
dispatch_id = recording.options.get("metadata_collector_dispatch_id")
lkapi = utils.create_livekit_client()
try:
if not dispatch_id:
logger.warning(
"No metadata collector dispatch ID stored for room %s", room_id
)
return None
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=room_id
)
except Exception as e:
logger.exception(
"Failed to stop metadata collector agent dispatch for room %s",
room_id,
)
raise MetadataCollectorException(
f"Failed to stop metadata collector agent for room {room_id}"
) from e
finally:
await lkapi.aclose()
@@ -7,6 +7,7 @@ from logging import getLogger
from livekit import api
from core import models, utils
from core.models import Recording
logger = getLogger(__name__)
@@ -19,7 +20,7 @@ class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
def handle_update(recording: Recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
@@ -40,7 +41,7 @@ class RecordingEventsService:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
def handle_limit_reached(recording: Recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
@@ -1,5 +1,7 @@
"""Factory, configurations and Protocol to create worker services"""
# pylint: disable=no-member
import logging
from dataclasses import dataclass
from functools import lru_cache
@@ -8,8 +10,17 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
from django.conf import settings
from django.utils.module_loading import import_string
from livekit import api as livekit_api
logger = logging.getLogger(__name__)
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
# Kept fixed because changing them would shift the goal-post away from the
# "safe drop-in replacement for the default preset" contract of this feature.
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
@dataclass(frozen=True)
class WorkerServiceConfig:
@@ -18,6 +29,7 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
bucket_args: Optional[dict]
encoding_options: Optional[Dict[str, Any]] = None
@classmethod
@lru_cache
@@ -25,6 +37,24 @@ class WorkerServiceConfig:
"""Load configuration from Django settings with caching for efficiency."""
logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
@@ -36,6 +66,7 @@ class WorkerServiceConfig:
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True,
},
encoding_options=encoding_options,
)
+27 -3
View File
@@ -83,6 +83,22 @@ class BaseEgressService:
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**opts)
class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
@@ -104,9 +120,17 @@ class VideoCompositeEgressService(BaseEgressService):
s3=self._s3,
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
request_kwargs = {
"room_name": room_name,
"file_outputs": [file_output],
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
if advanced is not None:
request_kwargs["advanced"] = advanced
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
response = self._handle_request(request, "start_room_composite_egress")
+13 -10
View File
@@ -4,7 +4,7 @@ import smtplib
from logging import getLogger
from django.conf import settings
from django.core.mail import send_mail
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
@@ -45,15 +45,18 @@ class InvitationService:
)
) # Force translation
email = EmailMultiAlternatives(
subject=subject,
body=msg_plain,
from_email=settings.EMAIL_FROM,
to=[],
bcc=emails,
)
email.attach_alternative(msg_html, "text/html")
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
email.send()
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
logger.error("invitations were not sent: %s", e)
raise InvitationError("Could not send invitation") from e
+11 -1
View File
@@ -12,6 +12,10 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -158,7 +162,7 @@ class LiveKitEventsService:
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
@@ -174,6 +178,12 @@ class LiveKitEventsService:
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
try:
MetadataCollectorService().stop(recording)
except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService")
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
+1 -1
View File
@@ -123,7 +123,7 @@ class LobbyService:
def request_entry(
self,
room,
room: models.Room,
request,
username: str,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
@@ -15,6 +15,7 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -27,6 +28,10 @@ class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail."""
class ParticipantNotFoundException(ParticipantsManagementException):
"""Raised when the target participant does not exist in the room."""
class ParticipantsManagement:
"""Service for managing participants."""
@@ -47,6 +52,14 @@ class ParticipantsManagement:
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping muting",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error muting participant %s for room %s",
identity,
@@ -80,6 +93,14 @@ class ParticipantsManagement:
RoomParticipantIdentity(room=room_name, identity=identity)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping removing",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error removing participant %s for room %s",
identity,
@@ -117,6 +138,14 @@ class ParticipantsManagement:
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping update",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error updating participant %s for room %s",
identity,
@@ -126,3 +155,44 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
Raises ParticipantsManagementException for unexpected LiveKit errors
so callers can fail closed rather than silently allowing the action.
"""
if not room_name or not identity:
return False
lkapi = utils.create_livekit_client()
try:
participant = await lkapi.room.get_participant(
RoomParticipantIdentity(
room=room_name,
identity=identity,
)
)
except TwirpError as e:
if e.code == "not_found":
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error checking participant %s in room %s",
identity,
room_name,
)
raise ParticipantsManagementException(
"Could not verify participant presence"
) from e
finally:
await lkapi.aclose()
return (
participant is not None
and participant.state != ParticipantInfo.State.DISCONNECTED
)
@@ -0,0 +1,64 @@
"""Room management service for LiveKit rooms."""
# pylint: disable=no-name-in-module
import json
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
TwirpError,
UpdateRoomMetadataRequest,
)
from core import utils
logger = getLogger(__name__)
class RoomManagementException(Exception):
"""Exception raised when a room management operation fails."""
class RoomNotFoundException(RoomManagementException):
"""Raised when the target room does not exist in LiveKit."""
class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
"Unexpected error updating metadata for room %s",
room_name,
)
raise RoomManagementException("Could not update room metadata") from e
finally:
await lkapi.aclose()
@@ -0,0 +1,601 @@
"""
Tests for add-ons API /sessions/init and /sessions/poll endpoints
"""
# pylint: disable=redefined-outer-name,unused-argument
import re
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core.addons.service import (
SessionDataError,
SessionExpiredError,
SessionNotFoundError,
SuspiciousSessionError,
TokenExchangeService,
)
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# ================================
# endpoint /addons/sessions/init/
# ================================
def test_init_feature_flag_disabled(client, settings):
"""Should return 404 on POST when feature is disabled."""
settings.ADDONS_ENABLED = False
response = client.post("/api/v1.0/addons/sessions/init/")
assert response.status_code == 404
def test_init_only_accepts_post():
"""Should return 201 JSON with only transit_token and csrf_token."""
response = APIClient().post("/api/v1.0/addons/sessions/init/")
assert response.status_code == 201
assert response["Content-Type"] == "application/json"
response_data = response.json()
# session_id must only be delivered via cookie, not in the same channel as csrf_token.
assert set(response_data.keys()) == {"transit_token", "csrf_token"}
transit_token = response_data["transit_token"]
# URL-safe base64 alphabet: A-Z, a-z, 0-9, -, _
assert re.match(r"^[A-Za-z0-9_-]+$", transit_token)
csrf_token = response_data["csrf_token"]
# HMAC-SHA256 → 64-character hex string.
assert re.match(r"^[a-f0-9]{64}$", csrf_token)
assert csrf_token != transit_token
def test_init_rejects_non_post_methods():
"""Should return 405 Method Not Allowed on GET."""
response = APIClient().get("/api/v1.0/addons/sessions/init/")
assert response.status_code == 405
def test_init_generates_unique_tokens_across_calls():
"""Should generate a distinct transit_token and csrf_token for every call."""
api_client = APIClient()
tokens = set()
csrf_tokens = set()
for _ in range(5):
response = api_client.post("/api/v1.0/addons/sessions/init/")
tokens.add(response.json()["transit_token"])
csrf_tokens.add(response.json()["csrf_token"])
assert len(tokens) == 5
assert len(csrf_tokens) == 5
def test_init_cookie_authorizes_subsequent_poll():
"""Should issue a session cookie that, with the returned csrf_token, authorizes /poll."""
api_client = APIClient()
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
assert "addonsSid" in init_response.cookies
csrf_token = init_response.json()["csrf_token"]
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 202
assert poll_response.json() == {"state": "pending"}
def test_init_session_id_cookie_attributes(settings):
"""Should set the session cookie with the security attributes required for iframe embedding."""
response = APIClient().post("/api/v1.0/addons/sessions/init/")
cookies = response.cookies
assert list(cookies) == ["addonsSid"] # only this cookie
cookie = cookies["addonsSid"]
assert re.match(r"^[A-Za-z0-9_-]+$", cookie.value), "URL-safe base64 expected"
assert cookie["httponly"] is True, (
"HttpOnly required — cookie must not be JS-readable"
)
assert cookie["secure"] is True, (
"Secure required — cookie must not travel over HTTP"
)
assert cookie["samesite"] == "None", (
"SameSite=None required for cross-origin iframe"
)
assert cookie["max-age"] == settings.ADDONS_SESSION_TTL
def test_init_session_id_cookie_respects_configured_name(settings):
"""Should name the session cookie according to the ADDONS_SESSION_ID_COOKIE setting."""
api_client = APIClient()
settings.ADDONS_SESSION_ID_COOKIE = "mockSessionSid"
response = api_client.post("/api/v1.0/addons/sessions/init/")
assert "mockSessionSid" in response.cookies
assert response.cookies.get("mockSessionSid") is not None
# =================================
# endpoint /addons/sessions/poll/
# =================================
def test_poll_feature_flag_disabled(client, settings):
"""Should return 404 on POST when feature is disabled."""
settings.ADDONS_ENABLED = False
response = client.post("/api/v1.0/addons/sessions/poll/")
assert response.status_code == 404
def test_poll_rejects_missing_csrf_token():
"""Should reject requests that carry the sessionSid cookie but omit the CSRF header."""
api_client = APIClient()
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
# X-CSRF-Token is deliberately omitted
poll_response = api_client.post("/api/v1.0/addons/sessions/poll/")
assert poll_response.status_code == 400
assert poll_response.json() == {"detail": "Missing CSRF token."}
def test_poll_missing_cookie():
"""Should return 401 when no sessionSid cookie is present."""
api_client = APIClient()
poll_response = api_client.post("/api/v1.0/addons/sessions/poll/")
assert poll_response.status_code == 401
assert poll_response.json() == {"detail": "Missing credentials."}
def test_poll_rejects_invalid_csrf_token():
"""Should reject requests carrying an invalid CSRF token."""
api_client = APIClient()
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN="invalid-csrf-token",
)
# SuspiciousOperation translates to 400 via Django's exception middleware.
assert poll_response.status_code == 400
@patch(
"core.addons.service.TokenExchangeService._get_session_data",
side_effect=SessionNotFoundError("Session not found."),
)
def test_poll_session_not_found(mock_get_session_data):
"""Should return 404 when the session is not found."""
api_client = APIClient()
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
csrf_token = init_response.json()["csrf_token"]
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 404
assert poll_response.json() == {"detail": "Session not found."}
@patch(
"core.addons.service.TokenExchangeService._get_session_data",
side_effect=SessionDataError("Session corrupted."),
)
def test_poll_session_corrupted(mock_get_session_data):
"""Should return 400 when the session is corrupted."""
api_client = APIClient()
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
csrf_token = init_response.json()["csrf_token"]
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 400
assert poll_response.json() == {"detail": "Invalid or expired session."}
def test_poll_session_authenticated():
"""Should return tokens and tears down the polling channel when authenticated."""
api_client = APIClient()
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
session_id_cookie = init_response.cookies["addonsSid"]
csrf_token = init_response.json()["csrf_token"]
transit_token = init_response.json()["transit_token"]
# Simulate Authentication done in the opened dialog
service = TokenExchangeService()
service.consume_transit_token(transit_token)
service.set_access_token(UserFactory(), session_id_cookie.value)
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 200
response_data = poll_response.json()
access_token = response_data.pop("access_token")
assert isinstance(access_token, str) and access_token # non-empty string
assert response_data == {
"expires_in": 7200,
"scope": "rooms:create",
"state": "authenticated",
"token_type": "Bearer",
}
# Verify the server cleared the addonsSid cookie
cleared_cookie = poll_response.cookies["addonsSid"]
assert cleared_cookie.value == ""
assert cleared_cookie["max-age"] == 0
# Server cleared the addonsSid cookie; APIClient drops it → no credentials.
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 401
# Replay the original addonsSid: session was evicted on terminal read.
api_client.cookies["addonsSid"] = session_id_cookie.value
poll_response = api_client.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 404
assert poll_response.json() == {"detail": "Session not found."}
def test_poll_two_clients_do_not_interfere():
"""Two clients poll independently; CSRF tokens are bound to their own session."""
client_a = APIClient()
client_b = APIClient()
init_a = client_a.post("/api/v1.0/addons/sessions/init/")
init_b = client_b.post("/api/v1.0/addons/sessions/init/")
assert init_a.status_code == 201
assert init_b.status_code == 201
csrf_a = init_a.json()["csrf_token"]
csrf_b = init_b.json()["csrf_token"]
poll_id_a = init_a.cookies["addonsSid"].value
poll_id_b = init_b.cookies["addonsSid"].value
# Sessions must be distinct.
assert csrf_a != csrf_b
assert poll_id_a != poll_id_b
# Each client polls its own session.
poll_a = client_a.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_a,
)
poll_b = client_b.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_b,
)
assert poll_a.status_code == 202
assert poll_b.status_code == 202
# Cross-use (A's cookie + B's CSRF) must be rejected.
cross_response = client_a.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_b,
)
assert cross_response.status_code == 400
# A's session transitioning to authenticated must not affect B.
with patch(
"core.addons.service.TokenExchangeService._get_session_data",
return_value={
"state": "authenticated",
"expires_at": "foo",
"access_token": "mock-token",
"token_type": "Bearer",
"expires_in": 100,
},
):
poll_a = client_a.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_a,
)
assert poll_a.status_code == 200
poll_b = client_b.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_b,
)
assert poll_b.status_code == 202
def test_poll_csrf_attack_does_not_disrupt_legitimate_client():
"""CSRF attack using the pollId cookie must fail without burning the session."""
legitimate = APIClient()
init_response = legitimate.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
csrf_token = init_response.json()["csrf_token"]
session_id_value = init_response.cookies["addonsSid"].value
# Attacker has the cookie (SameSite=None) but not the CSRF token.
attacker = APIClient()
attacker.cookies["addonsSid"] = session_id_value
# No CSRF header
attack_no_csrf = attacker.post("/api/v1.0/addons/sessions/poll/")
assert attack_no_csrf.status_code == 400
assert attack_no_csrf.json() == {"detail": "Missing CSRF token."}
# Fabricated CSRF token
attack_bad_csrf = attacker.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN="attacker-guessed-token",
)
assert attack_bad_csrf.status_code == 400
# Legitimate client's session is still usable.
legitimate_poll = legitimate.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert legitimate_poll.status_code == 202
assert legitimate_poll.json() == {"state": "pending"}
# =====================================
# endpoint /addons/sessions/exchange/
# =====================================
def test_exchange_feature_flag_disabled(settings):
"""Should return 404 on POST when feature is disabled."""
settings.ADDONS_ENABLED = False
api_client = APIClient()
api_client.force_authenticate(user=UserFactory())
response = api_client.post("/api/v1.0/addons/sessions/exchange/")
assert response.status_code == 404
def test_exchange_requires_authentication():
"""Should return 401 when the caller is not authenticated."""
api_client = APIClient()
response = api_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": "irrelevant"},
format="json",
)
assert response.status_code == 401
def test_exchange_rejects_missing_transit_token():
"""Should return 400 when the request body has no transit_token."""
api_client = APIClient()
api_client.force_authenticate(user=UserFactory())
response = api_client.post(
"/api/v1.0/addons/sessions/exchange/",
{},
format="json",
)
assert response.status_code == 400
assert response.json() == {"detail": "Missing transit_token."}
def test_exchange_rejects_empty_transit_token():
"""Should return 400 when transit_token is present but empty."""
api_client = APIClient()
api_client.force_authenticate(user=UserFactory())
response = api_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": ""},
format="json",
)
assert response.status_code == 400
assert response.json() == {"detail": "Missing transit_token."}
def test_exchange_rejects_invalid_transit_token():
"""Should return 400 when the transit token is unknown or malformed."""
api_client = APIClient()
api_client.force_authenticate(user=UserFactory())
response = api_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": "not-a-real-transit-token"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid or expired transit token."}
def test_exchange_rejects_replayed_transit_token():
"""Should return 400 when a transit token is reused after being consumed."""
init_client = APIClient()
init_response = init_client.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
transit_token = init_response.json()["transit_token"]
auth_client = APIClient()
auth_client.force_authenticate(user=UserFactory())
first = auth_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": transit_token},
format="json",
)
assert first.status_code == 200
second = auth_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": transit_token},
format="json",
)
assert second.status_code == 400
assert second.json() == {"detail": "Invalid or expired transit token."}
def test_exchange_success_enables_poll_to_complete():
"""Should bind tokens to the session so the polling completes."""
# 1. Taskpane opens a session.
taskpane = APIClient()
init_response = taskpane.post("/api/v1.0/addons/sessions/init/")
assert init_response.status_code == 201
transit_token = init_response.json()["transit_token"]
csrf_token = init_response.json()["csrf_token"]
# 2. Dialog completes OIDC; post-login page (authenticated, separate
# client — no addonsSid cookie) calls /exchange with the transit token.
dialog = APIClient()
dialog.force_authenticate(user=UserFactory())
exchange_response = dialog.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": transit_token},
format="json",
)
assert exchange_response.status_code == 200
assert exchange_response.json() == {"status": "ok"}
# 3. Taskpane's next poll transitions from pending → authenticated.
poll_response = taskpane.post(
"/api/v1.0/addons/sessions/poll/",
HTTP_X_CSRFTOKEN=csrf_token,
)
assert poll_response.status_code == 200
response_data = poll_response.json()
assert response_data["state"] == "authenticated"
assert response_data["token_type"] == "Bearer"
assert isinstance(response_data["access_token"], str)
assert response_data["access_token"]
@patch(
"core.addons.service.TokenExchangeService.set_access_token",
side_effect=SessionNotFoundError("Session not found."),
)
def test_exchange_returns_when_session_missing(mock_set_access_token):
"""Should return 404 when the session bound to the transit token is gone."""
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
transit_token = init_response.json()["transit_token"]
auth_client = APIClient()
auth_client.force_authenticate(user=UserFactory())
response = auth_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": transit_token},
format="json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Session not found."}
@pytest.mark.parametrize(
"service_error",
[SessionDataError, SessionExpiredError, SuspiciousSessionError],
)
def test_exchange_on_invalid_session(service_error):
"""Should return 400 on malformed, expired, or suspicious sessions."""
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
transit_token = init_response.json()["transit_token"]
auth_client = APIClient()
auth_client.force_authenticate(user=UserFactory())
with patch(
"core.addons.service.TokenExchangeService.set_access_token",
side_effect=service_error("boom"),
):
response = auth_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": transit_token},
format="json",
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid or expired session."}
def test_exchange_rejects_non_post_methods():
"""Should return 405 Method Not Allowed on non-POST verbs."""
api_client = APIClient()
api_client.force_authenticate(user=UserFactory())
for method in ("get", "put", "patch", "delete"):
response = getattr(api_client, method)("/api/v1.0/addons/sessions/exchange/")
assert response.status_code == 405, f"{method.upper()} should be rejected"
def test_exchange_binds_to_authenticated_user():
"""Should pass the authenticated user to set_access_token."""
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
transit_token = init_response.json()["transit_token"]
expected_user = UserFactory()
auth_client = APIClient()
auth_client.force_authenticate(user=expected_user)
with patch(
"core.addons.service.TokenExchangeService.set_access_token",
return_value=None,
) as mock_set:
response = auth_client.post(
"/api/v1.0/addons/sessions/exchange/",
{"transit_token": transit_token},
format="json",
)
assert response.status_code == 200
mock_set.assert_called_once()
called_user, _called_session_id = mock_set.call_args.args
assert called_user == expected_user
@@ -0,0 +1,586 @@
"""
Unit tests for TokenExchangeService.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
import pytest
from core.addons.service import (
_PUBLIC_SESSION_FIELDS,
CSRFTokenError,
SessionDataError,
SessionExpiredError,
SessionNotFoundError,
SessionState,
SuspiciousSessionError,
TokenExchangeService,
TransitTokenError,
TransitTokenState,
)
from ...factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def service():
"""Fresh service instance per test."""
return TokenExchangeService()
# ==============================
# init
# ==============================
def test_init_service_improperly_configured_secret_token(settings):
"""Should raise ImproperlyConfigured when ADDONS_TOKEN_SECRET_KEY is unset."""
settings.ADDONS_TOKEN_SECRET_KEY = None
with pytest.raises(ImproperlyConfigured, match="Secret key is required."):
TokenExchangeService()
def test_init_service_improperly_configured_token_scope(settings):
"""Should raise ImproperlyConfigured when ADDONS_TOKEN_SCOPE is empty."""
settings.ADDONS_TOKEN_SCOPE = None
with pytest.raises(ImproperlyConfigured, match="Token scope must be defined."):
TokenExchangeService()
def test_init_service_raises_when_csrf_secret_missing(settings):
"""Should raise ImproperlyConfigured when ADDONS_CSRF_SECRET is unset."""
settings.ADDONS_CSRF_SECRET = None
with pytest.raises(ImproperlyConfigured, match="CSRF Secret is required."):
TokenExchangeService()
# ==============================
# init_session
# ==============================
def test_init_session_returns_three_distinct_tokens(service):
"""Should return (transit_token, session_id, csrf_token), all distinct and non-empty."""
transit_token, session_id, csrf_token = service.init_session()
assert transit_token
assert session_id
assert csrf_token
assert len({transit_token, session_id, csrf_token}) == 3
def test_init_session_starts_in_pending_state(service):
"""Should return a pending initialized session."""
transit_token, session_id, _ = service.init_session()
session_data = cache.get(f"addons_sid_{session_id}")
assert session_data["state"] == SessionState.PENDING
assert "access_token" not in session_data
transit_data = cache.get(f"addons_transit_{transit_token}")
# Transit token should be bind to the same session
assert transit_data.get("session_id") == session_id
assert transit_data.get("state") == TransitTokenState.PENDING
def test_init_session_bind_transit_token_with_session(service):
"""Should bind transit_token with the initialized session."""
transit_token, session_id, _ = service.init_session()
transit_data = cache.get(f"addons_transit_{transit_token}")
assert transit_data.get("session_id") == session_id
assert transit_data.get("state") == TransitTokenState.PENDING
def test_init_session_creates_independent_cache_entries(service):
"""Should write to distinct cache keys when called multiple times."""
transit_a, session_id_a, csrf_a = service.init_session()
transit_b, session_id_b, csrf_b = service.init_session()
assert transit_a != transit_b
assert session_id_a != session_id_b
assert csrf_a != csrf_b
def test_init_session_csrf_token_is_derived_from_session_id(service, settings):
"""Should derive the csrf_token as HMAC(session_id, ADDONS_CSRF_SECRET)."""
_, session_id, csrf_token = service.init_session()
# Same inputs, same output: derivation is pure.
assert csrf_token == service._derive_csrf_token(session_id)
assert csrf_token == service._derive_csrf_token(
session_id
) # deterministic across calls
assert len(csrf_token) == 64
assert all(c in "0123456789abcdef" for c in csrf_token)
# CSRF token is bound to the secret: rotating it invalidates outstanding tokens.
settings.ADDONS_CSRF_SECRET = "another-secret-entirely"
assert csrf_token != service._derive_csrf_token(session_id)
# CSRF token is bound to the session_id: same secret, different session ≠ same token.
settings.ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # restore
_, other_session_id, _ = service.init_session()
assert service._derive_csrf_token(session_id) != service._derive_csrf_token(
other_session_id
)
def test_init_session_tokens_have_sufficient_entropy(service):
"""Should be long enough by default that collision is negligible."""
transit_token, session_id, csrf_token = service.init_session()
assert len(transit_token) >= 40
assert len(session_id) >= 40
assert len(csrf_token) == 64
def test_init_session_respects_configured_ttls(service, settings):
"""Should respect their respective TTL configured through settings."""
transit_token, session_id, _ = service.init_session()
session_a_ttl = cache.ttl(f"addons_sid_{session_id}")
transit_a_ttl = cache.ttl(f"addons_transit_{transit_token}")
# By default, transit token has a shorter TTL
assert transit_a_ttl < session_a_ttl
settings.ADDONS_SESSION_TTL = 3000
settings.ADDONS_TRANSIT_TOKEN_TTL = 60
transit_token_b, session_id_b, _ = service.init_session()
session_b_ttl = cache.ttl(f"addons_sid_{session_id_b}")
transit_b_ttl = cache.ttl(f"addons_transit_{transit_token_b}")
assert abs(session_b_ttl - 3000) <= 2
assert abs(transit_b_ttl - 60) <= 2
# ==============================
# verify_csrf
# ==============================
def test_verify_csrf_accepts_matching_token(service):
"""Should verify against its session_id."""
_, session_id, csrf_token = service.init_session()
assert service.verify_csrf(session_id, csrf_token) is None
def test_verify_csrf_is_deterministic_for_same_session(service):
"""Should yield the same token when deriving CSRF twice."""
_, session_id, csrf_token = service.init_session()
# Verify once, then verify again, both must succeed because
# _derive_csrf_token is a pure function of session_id + secret.
# without raising exceptions;
assert service.verify_csrf(session_id, csrf_token) is None
assert service.verify_csrf(session_id, csrf_token) is None
def test_verify_csrf_rejects_after_secret_rotation(service, settings):
"""Should invalidate tokens issued under the old secret when ADDONS_CSRF_SECRET was rotated."""
_, session_id, csrf_token = service.init_session()
# Rotate the secret
settings.ADDONS_CSRF_SECRET = "different-secret-entirely"
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
service.verify_csrf(session_id, csrf_token)
def test_verify_csrf_rejects_foreign_token(service):
"""Should reject against another csrf_token."""
_, session_id_a, _ = service.init_session()
_, _, csrf_b = service.init_session()
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
service.verify_csrf(session_id_a, csrf_b)
def test_verify_csrf_rejects_random_token(service):
"""Should reject against a random csrf_token."""
_, session_id_a, _ = service.init_session()
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
service.verify_csrf(session_id_a, "wrong-csrf-value")
def test_verify_csrf_rejects_empty_token(service):
"""Should reject against an empty csrf_token."""
_, session_id_a, _ = service.init_session()
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
service.verify_csrf(session_id_a, "")
def test_verify_csrf_is_case_sensitive(service):
"""Should be case-sensitive (HMAC output is lowercase hex)."""
_, session_id, csrf_token = service.init_session()
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
service.verify_csrf(session_id, csrf_token.upper())
# ==============================
# get_session
# ==============================
def test_get_session_raises_when_missing(service):
"""Should raise SessionNotFoundError for an unknown session_id."""
with pytest.raises(SessionNotFoundError, match="Session not found."):
service.get_session("nonexistent-session-id")
def test_get_session_authenticated_returns_token_then_evicts(service):
"""Should return tokens once and evict session when authenticated."""
user = UserFactory()
transit_token_a, session_id_a, _ = service.init_session()
_, session_id_b, _ = service.init_session()
# Authenticate the session
service.consume_transit_token(transit_token_a)
service.set_access_token(user, session_id_a)
# First read: returns the token payload.
result = service.get_session(session_id_a)
assert result["state"] == SessionState.AUTHENTICATED
assert "access_token" in result
# Assert session_a is evicted from the cache
session_data_a = cache.get(f"addons_sid_{session_id_a}")
assert session_data_a is None
# Second read: binding was evicted.
with pytest.raises(SessionNotFoundError, match="Session not found."):
service.get_session(session_id_a)
# Assert session_b is untouched
session_data_b = cache.get(f"addons_sid_{session_id_b}")
assert session_data_b is not None
assert session_data_b.get("state") == SessionState.PENDING
def test_get_session_pending_preserve_cache(service):
"""Should keep session state in cache when the session is pending."""
_, session_id, _ = service.init_session()
# First read: returns the pending session.
result_1 = service.get_session(session_id)
assert result_1["state"] == SessionState.PENDING
assert "access_token" not in result_1
# Second read: returns the pending session.
result_2 = service.get_session(session_id)
assert result_2["state"] == SessionState.PENDING
assert "access_token" not in result_2
def test_get_session_pending_only_exposes_public_fields(service):
"""Should only return whitelisted public fields when session is pending."""
_, session_id, _ = service.init_session()
session = service.get_session(session_id)
assert set(session.keys()) <= _PUBLIC_SESSION_FIELDS
assert session["state"] == SessionState.PENDING
assert "expires_at" not in session
assert "transit_token" not in session
def test_get_session_authenticated_only_exposes_public_fields(service):
"""Should only return whitelisted public fields when session is authenticated."""
transit_token, session_id, _ = service.init_session()
# Authenticate the session
user = UserFactory()
service.consume_transit_token(transit_token)
service.set_access_token(user, session_id)
session = service.get_session(session_id)
assert session["state"] == SessionState.AUTHENTICATED
assert set(session.keys()) <= _PUBLIC_SESSION_FIELDS
assert "expires_at" not in session
assert "transit_token" not in session
def test_get_session_empty_string(service):
"""Should raise SessionNotFoundError if session is empty."""
with pytest.raises(SessionNotFoundError, match="Session not found."):
service.get_session("")
def test_get_session_corrupted_session_data(service):
"""Should raise SessionDataError if session's data is corrupted."""
session_id = "mock-corrupted-session-id"
cache.set(f"addons_sid_{session_id}", {"invalid": "invalid-value"})
with pytest.raises(
SessionDataError, match="Invalid session data: missing state field."
):
service.get_session(session_id)
# ==============================
# consume_transit_token
# ==============================
def test_consume_transit_token_returns_session_id(service):
"""Should return the session_id the transit token was bound to."""
_, session_id, _ = service.init_session()
transit_token = cache.get(f"addons_sid_{session_id}")["transit_token"]
returned_session_id = service.consume_transit_token(transit_token)
assert returned_session_id == session_id
def test_consume_transit_token_replay_raises(service):
"""Should raise on the second consume of the same transit token."""
transit_token, _, _ = service.init_session()
service.consume_transit_token(transit_token)
with pytest.raises(TransitTokenError, match="Transit token already consumed."):
service.consume_transit_token(transit_token)
def test_consume_transit_token_replay_evicts_session(service):
"""Should evict the session as security cleanup when a replay is detected."""
transit_token, session_id, _ = service.init_session()
service.consume_transit_token(transit_token)
assert service.get_session(session_id)
with pytest.raises(TransitTokenError):
service.consume_transit_token(transit_token)
# After replay, the session is gone.
with pytest.raises(SessionNotFoundError):
service.get_session(session_id)
def test_consume_transit_token_raises_on_unknown_token(service):
"""Should raise TransitTokenError when the transit token is unknown or expired."""
with pytest.raises(TransitTokenError, match="Invalid or expired transit token."):
service.consume_transit_token("nonexistent-transit-token")
def test_consume_transit_token_replay_when_session_already_gone(service):
"""Should still detect replay even if the session was evicted independently."""
transit_token, session_id, _ = service.init_session()
service.consume_transit_token(transit_token)
# Simulate session evicted independently
cache.delete(f"addons_sid_{session_id}")
with pytest.raises(TransitTokenError, match="Transit token already consumed."):
service.consume_transit_token(transit_token)
def test_consume_transit_token_extends_ttl_for_replay_detection(service, settings):
"""Should extend the consumed transit entry's TTL to session length."""
settings.ADDONS_SESSION_TTL = 3000
settings.ADDONS_TRANSIT_TOKEN_TTL = 60
transit_token, _, _ = service.init_session()
# Before consume: transit has the short TTL.
assert cache.ttl(f"addons_transit_{transit_token}") <= 60 + 1
service.consume_transit_token(transit_token)
# After consume: TTL is extended to session length.
assert cache.ttl(f"addons_transit_{transit_token}") > 60
# ==============================
# set_access_token
# ==============================
def test_set_access_token_writes_jwt_fields_to_session(service, settings):
"""Should populate the session with JWT fields and flip state to authenticated."""
user = UserFactory()
transit_token, session_id, _ = service.init_session()
service.consume_transit_token(transit_token)
service.set_access_token(user, session_id)
session = service.get_session(session_id)
assert session["state"] == SessionState.AUTHENTICATED
assert session["access_token"]
assert session["token_type"] == settings.ADDONS_TOKEN_TYPE
assert session["expires_in"] == settings.ADDONS_TOKEN_TTL
assert session["scope"] == settings.ADDONS_TOKEN_SCOPE
def test_set_access_token_preserves_remaining_ttl(service, settings):
"""Should inherit the pending session's remaining TTL rather than resetting it."""
settings.ADDONS_SESSION_TTL = 3000
user = UserFactory()
transit_token, session_id, _ = service.init_session()
service.consume_transit_token(transit_token)
ttl_before = cache.ttl(f"addons_sid_{session_id}")
service.set_access_token(user, session_id)
ttl_after = cache.ttl(f"addons_sid_{session_id}")
# TTL must not jump back to full — allow small tolerance for execution time.
assert ttl_after <= ttl_before + 1
# And it shouldn't have somehow grown beyond the session length either.
assert ttl_after <= 3000
def test_authenticating_one_session_leaves_others_pending(service):
"""Should leave other pending sessions untouched when authenticating one."""
user = UserFactory()
transit_a, session_id_a, _ = service.init_session()
_, session_id_b, _ = service.init_session()
service.consume_transit_token(transit_a)
service.set_access_token(user, session_id_a)
session_b = service.get_session(session_id_b)
assert session_b["state"] == SessionState.PENDING
assert "access_token" not in session_b
def test_set_access_token_raises_when_transit_entry_missing(service):
"""Should raise when the transit cache entry is gone (TTL expired or evicted)."""
user = UserFactory()
transit_token, session_id, _ = service.init_session()
# Manually delete the transit entry, simulating expiry or eviction.
cache.delete(f"addons_transit_{transit_token}")
with pytest.raises(SuspiciousSessionError, match="Transit token not found."):
service.set_access_token(user, session_id)
def test_set_access_token_raises_if_transit_token_not_consumed(service):
"""Should refuse to authenticate a session whose transit token hasn't been consumed."""
user = UserFactory()
_, session_id, _ = service.init_session()
with pytest.raises(SuspiciousSessionError, match="Transit token not consumed."):
service.set_access_token(user, session_id)
assert cache.get(f"addons_sid_{session_id}") is None
def test_set_access_token_raises_on_missing_transit_token_field(service):
"""Should raise SessionDataError when session data is missing the transit_token field."""
user = UserFactory()
transit_token, session_id, _ = service.init_session()
service.consume_transit_token(transit_token)
corrupted = cache.get(f"addons_sid_{session_id}")
del corrupted["transit_token"]
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
with pytest.raises(SessionDataError, match="missing transit_token field"):
service.set_access_token(user, session_id)
def test_set_access_token_raises_if_double_authenticated(service):
"""Should raise and wipe the session on double-auth while leaving the transit token intact."""
user = UserFactory()
transit_token, _, _ = service.init_session()
session_id = service.consume_transit_token(transit_token)
service.set_access_token(user, session_id)
with pytest.raises(
SuspiciousSessionError, match="Session is not in pending state."
):
service.set_access_token(user, session_id)
# Nuke session data as a security cleanup
session_data = cache.get(f"addons_sid_{session_id}")
assert session_data is None
transit_data = cache.get(f"addons_transit_{transit_token}")
assert transit_data.get("state") == TransitTokenState.CONSUMED
def test_set_access_token_raises_when_session_missing(service):
"""Should raise SessionNotFoundError when called with an unknown session_id."""
user = UserFactory()
with pytest.raises(SessionNotFoundError, match="Session not found."):
service.set_access_token(user, "nonexistent-session-id")
def test_set_access_token_rejects_malformed_expires_at(service):
"""Should raise SessionDataError when the cached expires_at is not valid ISO 8601."""
user = UserFactory()
transit_token, _, _ = service.init_session()
session_id = service.consume_transit_token(transit_token)
# Corrupt the cached session directly.
corrupted = cache.get(f"addons_sid_{session_id}")
corrupted["expires_at"] = "not-an-iso-string"
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
with pytest.raises(SessionDataError, match="malformed expiration"):
service.set_access_token(user, session_id)
def test_set_access_token_rejects_missing_expires_at(service):
"""Should raise SessionDataError when the cached session is missing the expires_at field."""
user = UserFactory()
transit_token, _, _ = service.init_session()
session_id = service.consume_transit_token(transit_token)
corrupted = cache.get(f"addons_sid_{session_id}")
del corrupted["expires_at"]
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
with pytest.raises(SessionDataError, match="missing expiration"):
service.set_access_token(user, session_id)
def test_set_access_token_raises_when_session_expired(service):
"""Should raise SessionExpiredError when the cached session's expires_at is in the past."""
user = UserFactory()
transit_token, session_id, _ = service.init_session()
service.consume_transit_token(transit_token)
# Simulate expiry: rewrite expires_at into the past.
corrupted = cache.get(f"addons_sid_{session_id}")
corrupted["expires_at"] = "2020-01-01T00:00:00+00:00"
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
with pytest.raises(SessionExpiredError, match="Session expired."):
service.set_access_token(user, session_id)
@@ -117,8 +117,8 @@ def test_api_files_create_file_authenticated_success():
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"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
@@ -174,6 +174,26 @@ def test_api_files_create_file_authenticated_extension_case_insensitive():
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.
@@ -239,6 +259,48 @@ def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_
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()
@@ -39,7 +39,7 @@ def test_api_files_update_anonymous_forbidden():
def test_api_files_update_description_and_title():
"""
Test the description and title of an file can be updated.
Test the description and title of a file can be updated.
"""
user = factories.UserFactory()
@@ -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):

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