Compare commits

..

229 Commits

Author SHA1 Message Date
lebaudantoine 883289bc6f fixup! wip init endpoint for addons/sessions 2026-01-30 13:53:34 +01:00
lebaudantoine 619c961598 wip init endpoint for addons/sessions 2026-01-26 18:59:32 +01:00
lebaudantoine 136d2d610b wip refactor jwt authentication 2026-01-26 16:26:27 +01:00
lebaudantoine e4e2c15505 wip refactor token generation 2026-01-26 16:26:27 +01:00
lebaudantoine 289a24545d wip configure external application api 2026-01-26 16:26:27 +01:00
lebaudantoine 99a18b6e90 🩹(backend) use case-insensitive email matching in the external api
Fix a minor issue in the external API where users were matched using
case-sensitive email comparison, while authentication treats emails as
case-insensitive. This caused inconsistencies that are now resolved.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

It closes #837.

WhisperX is expected to support both languages.
2026-01-06 17:52:04 +01:00
renovate[bot] d7ad5aed05 ⬆️(dependencies) update aiohttp to v3.13.3 [SECURITY] 2026-01-06 17:00:00 +01:00
lebaudantoine 4acc9cf40d 🩹(frontend) render the NoAccessView for unprevileged users
Simplify a broken conditional check that allowed users without
the required permissions to see the control menu. The `NoAccessView`
is now shown to any user who is neither an admin nor the meeting owner.
2026-01-06 16:43:15 +01:00
lebaudantoine 13d0d3d801 📈(frontend) track metadata on recording-started events
I introduced transcript + screen recording modes but forgot
to properly track them in PostHog. Fix this issue.
2026-01-06 16:43:15 +01:00
lebaudantoine 47cd3eff74 🔖(minor) bump release to 1.2.0 2026-01-05 18:10:05 +01:00
lebaudantoine 5769203705 💄(frontend) add minor layout adjustments
Propose minor layout adjustments to ensure the DINUM version with French
copywriting does not look visually awkward due to line breaks.
2026-01-05 17:47:26 +01:00
lebaudantoine cadc186c62 🐛(backend) fix certificates volume mount path for Python 3.13
After upgrading Python to 3.13, not all development environments were
updated accordingly. This fixes the incorrect volume mount path
introduced by that upgrade.
2026-01-05 17:47:26 +01:00
lebaudantoine 5be7595533 🐛(summary) fix MinIO endpoint handling in constructor
Fix MinIO client configuration: I was incorrectly passing a full URL instead of
an endpoint, which caused errors in staging. Local development values did not
reflect the staging setup and were also out of sync with the backend.
2026-01-05 15:40:11 +01:00
lebaudantoine 0fe8d9b681 🐛(backend) fix ignore recording webhook events
Fix an unexpected behavior where filtering LiveKit webhook events sometimes
failed because the room name was not reliably extracted from the webhook data,
causing notifications to be ignored.

Configure the same filtering logic locally to avoid missing this kind of issue
in the future.
2026-01-05 13:34:55 +01:00
lebaudantoine 83654cf7c0 📌(egress) pin egress version to v1.11.0
Pin egress to the production version, which uses a more recent release than the
default chart value (1.9.0).

Using the default could have led to issues; hopefully this change avoids them.
2026-01-05 11:00:12 +01:00
lebaudantoine f6cdb1125b ♻️(backend) refactor backend recording state management
Instead of relying on the egress_started event—which fires when egress is
starting, not actually started—I now rely on egress_updated for more accurate
status updates. This is especially important for the active status, which
triggers after egress has truly joined the room. Using this avoids prematurely
stopping client-side listening to room.isRecording updates. A further
refactoring may remove reliance on room updates entirely.

The goal is to minimize handling metadata in the mediator class. egress_starting
is still used for simplicity, but egress_started could be considered in the
future.

Note: if the API to start egress hasn’t responded yet, the webhook may fail to
find the recording because it currently matches by worker ID. This is unstable.
A better approach would be to pass the database ID in the egress metadata and
recover the recording from it in the webhook.
2026-01-05 00:14:00 +01:00
lebaudantoine 2863aa832d 🔥(frontend) remove useless font block on icons
Myabd, this css property is useful only on font face, it has
no effect on materials-related classes.
2026-01-05 00:14:00 +01:00
lebaudantoine 48af2e3a5f 📝(changelog) list all the recent recording-related enhancements
Worked on a large PR (#827) and chose to consolidate all new features and
refactorings in the changelog at the end of the work instead of updating it per
commit. Not ideal—acknowledge this is bad practice.
2026-01-04 20:22:15 +01:00
lebaudantoine 8a0dfd1478 🩹(frontend) make recording statuses more accurate
Link recording statuses to the `isRecording` attribute from the room on the
client side.

After the refactor, the frontend relied only on recording statuses computed by
the backend. However, when egress is started and the backend is notified, the
recording is not actually active yet. It takes some time for the egress to join
the room and begin recording.

Enrich the frontend by combining backend statuses with the room recording state
to more accurately reflect when recording is truly active. This avoids missing
the first few seconds of audio at the beginning of a recording.
2026-01-04 20:22:15 +01:00
lebaudantoine 37a2f3985a 🛂(frontend) display transcription settings for privileged users
Only display transcription settings to room admins or owners. Showing these
controls to users without the required privileges would be misleading, since
they cannot actually configure or apply the settings.
2026-01-04 20:22:15 +01:00
lebaudantoine 39271544d7 (summary) link transcript to their downloadable recording
Link the transcription document to its related recording by adding a short
header explaining that users can download the audio file via a dedicated link.

This was a highly requested feature, as many users need to keep their audio
files.

As part of a small refactor, remove the argument length check in the metadata
analytics class. The hardcoded argument count made code evolution harder and was
easy to forget updating. Argument unwrapping remains fragile and should be
redesigned later to be more robust.

The backend is responsible for generating the download link to ensure
consistency and reliability.

I tried adding a divider, but the Markdown-to-Yjs conversion is very lossy and
almost never handles it correctly. Only about one out of ten conversions works
as expected.
2026-01-04 20:22:15 +01:00
lebaudantoine f7b45622bc 🚸(frontend) enhance recording state toast icon
Specify distinct icons in the recording state toast for each mode to provide
clearer visual feedback on what is actually happening. Remove the pulse CSS
animation, as it did not improve visual clarity and accessibility.
2026-01-04 20:22:15 +01:00
lebaudantoine f3e2bbf701 (frontend) allow user to request recording
Inspired by @ericboucher’s proposal, allow non-admin or non-owner participants
to request the start of a transcription or a recording.

All participants are notified of the request, but only the admin can actually
open the menu and start the recording.

This is a first simple and naive implementation and will be improved later.

Prefer opening the relevant recording menu for admins instead of offering a
direct quick action to start recording. With more options now tied to recording,
keeping the responsibility for starting it encapsulated within the side panel
felt cleaner.

This comes with some UX trade-offs, but it’s worth trying.

I also simplified the notification mechanism by disabling the action button for
the same duration as the notification, preventing duplicate triggers. This is
not perfect, since hovering the notification pauses its display, but it avoids
most accidental re-triggers.
2026-01-04 20:22:15 +01:00
lebaudantoine 6e1ad7fca5 🚸(frontend) introduce an icon on the login prompt for visual distinction
This will be useful when adding an alternative card to request the meeting
creator to start the recording.
2026-01-04 20:22:15 +01:00
lebaudantoine d9dbededee 🚸(frontend) enhance the visual hierarchy of the no access view
Rework the visual hierarchy of the “no access” view to align it with other
presentation modes and ensure the title order is clear and understandable for
users.
2026-01-04 20:22:15 +01:00
lebaudantoine 70403ad0d8 (frontend) handle another recording mode is active
Refactor literals in the recording status hook and introduce a new status.
Align the login prompt style with the newly introduced warning message, and
guide users by clearly indicating that the two modes are mutually exclusive.
Users are prompted to stop the other mode before starting a new one.

This situation should happen less often now that checkboxes allow users to start
transcription and recording together. Hopefully, the UX is clear enough.

The growing number of props passed to the controls buttons may become an issue
and will likely require refactoring later.
2026-01-04 20:22:15 +01:00
lebaudantoine 9d69fe4f4f ♻️(frontend) introduce a recording mutation hook
Mutualize and factorize the recording API error modal in a single place, and
extract all recording mutations into a dedicated hook exposing both start and
stop actions.

This hook is responsible for interacting with the API error dialog when needed.
Previously, this logic was duplicated across each side panel; centralizing it
clarifies responsibilities and reduces duplication.
2026-01-04 20:22:15 +01:00
lebaudantoine 08f281e778 ♻️(frontend) introduce a recording provider with clear responsibilities
This component is now extensible and way easier to understand.

Previously, the recording state toast was implicitly acting as a provider,
making its core responsibility unclear for developers. Its role is not to
inject all recording-related elements into the videoconference DOM, but to
expose a clean recording state toast reflecting the current recording status.

This commit also fixes the limit-reached modal that was no longer appearing
after the refactor, ensures the modal is always rendered,
and removes unused React ARIA labels.

In the original code, the limit reached dialog was wrongly rendered
only when the recording state toast was null.
It was a bug in the implementation. Fix it.
2026-01-04 20:22:15 +01:00
lebaudantoine da3dfedcbc (frontend) update recording metadata alongside recording state changes
Following the previous commit, refactor the frontend to rely on room metadata to
track which recording is running and update the interface accordingly. This
implementation is not fully functional yet.

The limit-reached dialog triggering mechanism is currently broken and will be
fixed in upcoming commits. I also simplified the interface lifecycle, but some
edge cases are not yet handled—for example, transcription controls should be
disabled when a screen recording is started. This will be improved soon.

Controls were extracted into a reusable component using early returns. This
makes the logic easier to read, but slightly increases the overall complexity of
the recording side panel component.

Relying on literals to manage recording statuses is quite poor, feel free to
enhance this part.
2026-01-04 20:22:15 +01:00
lebaudantoine 16badde82d 🚧(backend) update recording metadata alongside recording state changes
Previously, this was handled manually by the client, sending notifications to
other participants and keeping the recording state only in memory. There was no
shared or persisted state, so leaving and rejoining a meeting lost this
information. Delegating this responsibility solely to the client was a poor
choice.

The backend now owns this responsibility and relies on LiveKit webhooks to keep
room metadata in sync with the egress lifecycle.

This also reveals that the room.isRecording attribute does not update as fast
as the egress stop event, which is unexpected and should be investigated
further.

This will make state management working when several room’s owner will be in
the same meeting, which is expected to arrive any time soon.
2026-01-04 20:22:15 +01:00
lebaudantoine 57a7523cc4 ♻️(frontend) extract recording row layout in reusable component
Now that screen recording and transcription share the same UI presentation,
extract the row logic into a reusable component to avoid code duplication and
improve code maintainability.
2026-01-04 20:22:15 +01:00
lebaudantoine 398ef1ae8a ♻️(frontend) encapsulate transcript language logic in a hook
Provide a clear interface to handle transcription language selection and
behavior, reducing code duplication across the codebase.
2026-01-04 20:22:15 +01:00
lebaudantoine f7d463f380 ♻️(frontend) encapsulate recording maximum duration handling
Centralize the logic to compute, internationalize, and present the maximum
recording duration in a human-readable way, reducing duplication across the
codebase.
2026-01-04 20:22:15 +01:00
lebaudantoine 5e1705d259 🚸(frontend) align screen recording side panel ux
Refactor the screen recording side panel to align with the transcription UX,
ensuring a more consistent and homogeneous user experience.

This commit also introduces a checkbox allowing users to request transcription
of the screen recording, which is one of the most requested features.

The side panel will be enriched with more information soon, especially once
Fichier is integrated for storing recordings, so the destination can be made
explicit.

More recording settings (layout, quality, etc.) will be introduced in upcoming
commits.
2026-01-04 20:22:15 +01:00
lebaudantoine 236245740f ♻️(frontend) refactor recording side panels to reduce code duplication
A lot of duplication existed, so I started factorizing components
now that a proper user experience is clearer.

Without over-abstracting, the first step introduces a reusable
“no access” view with configurable message and image.

This is just the beginning: props passing is still not ideal, but
it’s sufficient to merge and significantly reduce duplication.
2026-01-04 20:22:15 +01:00
lebaudantoine 9ebf2f277b 🔊(summarize) log language with more details
Enhance transcription language logging by explicitly indicating
when no language is provided and the code falls back to automatic
detection mode.
2026-01-04 20:22:15 +01:00
lebaudantoine 049a9079c4 (frontend) chose transcription’s language in settings
Add a key feature allowing users to choose the language
of their transcription via a setting.

The default value is set to French, the most commonly used
language across our user base.

Users can still select English or “Automatic,” which re-enables automatic
language detection if no default is configured on the microservice.
2026-01-04 20:22:15 +01:00
lebaudantoine 19f8c96e9d (frontend) allow parametrization of the transcrip document destination
Not all self-hosted instances will configure this setting, so a default text is
shown when the destination is unknown.

This is important to let users quickly click the link and understand which
platform is used to handle the transcription documents.
2026-01-04 20:22:15 +01:00
lebaudantoine 857b4bd1f1 (summary) handle video files more efficiently
Video files are heavy recording files, sometimes several hours long.

Previously, recordings were naively submitted to the Whisper API without
chunking, resulting in very large requests that could take a long time
to process. Video files are much larger than audio-only files, which
could cause performance issues during upload.

Introduce an extra step to extract the audio component from MP4 files,
producing a lighter audio-only file (to be confirmed). No re-encoding
is done, just a minimal FFmpeg extraction based on community guidance,
since I’m not an FFmpeg expert.

This feature is experimental and may introduce regressions, especially
if audio quality or sampling is impacted, which could reduce Whisper’s
accuracy. Early tests with the ASR model worked, but it has not been
tested on long recordings (e.g., 3-hour meetings),
which some users have.
2026-01-04 20:22:15 +01:00
lebaudantoine 309c532811 (backend) submit screen recordings to the summary microservice
Screen recording are MP4 files containing video)

The current approach is suboptimal: the microservice will later be updated to
extract audio paths from video, which can be heavy to send to the Whisper
service.

This implementation is straightforward, but the notification service is now
handling many responsibilities through conditional logic. A refactor with a
more configurable approach (mapping attributes to processing steps via
settings) would be cleaner and easier to maintain.
For now, this works; further improvements can come later.

I follow the KISS principle, and try to make this new feature implemented
with the lesser impact on the codebase. This isn’t perfect.
2026-01-04 20:22:15 +01:00
lebaudantoine 4e5032a7a4 ♻️(summary) enhance file handling in the Celery worker
The previous code lacked proper encapsulation, resulting in an overly complex
worker. While the initial naive approach was great for bootstrapping the
feature, the refactor introduces more maturity with dedicated service classes
that have clear, single responsibilities.

During the extraction to services, several minor issues were fixed:

1) Properly closing the MinIO response.

2) Enhanced validation of object filenames and extensions to ensure
correct file handling.

3) Introduced a context manager to automatically clean up temporary
local files, removing reliance on developers.

4) Slightly improved logging and naming for clarity.

5) Dynamic temporary file extension handling when it was previously
always an hardcoded .ogg file, even when it was not the case.
2026-01-04 20:22:15 +01:00
lebaudantoine 4cb6320b83 (summary) add a language parameter for transcription
Pass recording options’ language to the summary service, allowing users to
personalize the recording language.

This is important because automatic language detection often fails, causing
empty transcriptions or 5xx errors from the Whisper API. Users then do not
receive their transcriptions, which leads to frustration. For most of our
userbase, meetings are in French, and automatic detection is unreliable.

Support for language parameterization in the Whisper API has existed for some
time; only the frontend and backend integration were missing.

I did not force French as the default, since a minority of users hold English or
other European meetings. A proper settings tab to configure this value will be
introduced later.
2026-01-04 20:22:15 +01:00
lebaudantoine 587a5bc574 (frontend) allow starting both a recording and a transcription
Major user feature request: allow starting recording and transcription
simultaneously. Inspired by Google Meet UX, add a subtle checkbox letting users
start a recording alongside transcription.

The backend support for this feature is not yet implemented and will come in
upcoming commits, I can only pass the options to the API. The update of the
notification service will be handled later.
We’re half way with a functional feature.

This is not enabled by default because screen recording is resource-intensive. I
prefer users opt in rather than making it their default choice until feature
usage and performance stabilize.
2026-01-04 20:22:15 +01:00
lebaudantoine 0d8c76cd03 (backend) add a flexible JSON field to store recording options
Using a JSON field allows iterating on recording data without running a new
migration each time additional options or metadata need to be tracked.

This comes with trade-offs, notably weaker data validation and less clarity on
which data can be stored alongside a recording.

In the long run, this JSON field can be refactored into dedicated columns once
the feature and data model have stabilized.
2026-01-04 20:22:15 +01:00
lebaudantoine b19ac7f82b 🚸(frontend) rework the transcription side panel
Inspired by proprietary solutions, add clearer details on how transcription
works and what users can expect from the feature. This new presentation is much
simpler to read, parse, and understand than the previous large block of text
that users were not reading at all.

Using icons helps users quickly understand where the transcription is sent, how
they are notified, and which meeting language is used.

Some information is currently hardcoded and will be parameterized in upcoming
commits. This work is ongoing.
2026-01-04 20:22:15 +01:00
lebaudantoine d3e6af6f82 🚸(frontend) rework the meeting tools side panel UX
Explicitly explain that transcription is reserved for public servants. Remove
the temporary beta form: the feature is now available to all public servants,
with restrictions based on domain. Make white-labeling rules explicit and
clarify who to contact for access.

The beta form created frustration, with users registering and never hearing
back from the team.

Improve guidance when a user may be the meeting host but is not logged in, and
therefore cannot activate recording. Add a clear hint and a quick action to log
in. This decision is based on frequent support requests where users could not
understand why recording was unavailable while they were simply not logged in.
2026-01-04 20:22:15 +01:00
lebaudantoine 2fbb476b02 🔥(frontend) remove beta tag on recording feature
Initially, I thought presenting the recording feature as a beta would clearly
signal that it was still under construction and being improved. In practice, it
sent a negative signal to users, reduced trust, and still generated many
questions for the support team.

Without clearly explaining why the feature was in beta or what was coming next,
the label only added confusion. I chose to simplify the interface and remove the
beta indication altogether.
2026-01-04 20:22:15 +01:00
lebaudantoine 1b2139a9ff 💄(frontend) refactor meeting tools presentation
Follow Robin’s suggestion on the meeting tool layout presentation. The result
does not yet exactly match the Figma design, and I took some freedom to stay
closer to a Google Meet–like layout.

In the initial approach, it was hard to understand that the full option was
clickable. Adding a light background improves discoverability and usability.
2026-01-04 20:22:15 +01:00
lebaudantoine 54e47e33a9 🔧(frontend) configure Material Icons and Symbols
Robin chose to adopt Material Design icons, inspired by NVasse’s commit on
Fichier. This sets up the required CSS to easily use Material Icons throughout
the application.

Eventually, all icons in the app will be replaced with Material ones. For now,
the setup is only used in the recording UI refactor.
2026-01-04 20:22:15 +01:00
lebaudantoine 20b99cf2ad 🚸(frontend) simplify recording wording
Simplify wording and presentation of the recording feature heading,
using a more concise and familiar product-style language inspired by
well-known proprietary solutions.
2026-01-04 20:22:15 +01:00
lebaudantoine db75b0eae9 📱(frontend) solve recording responsiveness issue
Many public servants use PCs with unusual screen resolutions. The screen
height is often quite small, which caused responsiveness issues on the
vertical axis.

When opening the side panel, they could not see the button to start the
recording. I improved the vertical responsiveness to address this issue and
reduce support requests such as “I cannot see the button”.

Users typically do not think about scrolling inside the side panel, so the
layout now better fits constrained screen heights.
2026-01-04 20:22:15 +01:00
lebaudantoine 5163f849e4 ♻️(frontend) enhance feedback banner copywritting
Eliminate the perception of being 'under development,'
which can undermine trust with potential users.

Focus on creating a more confident and reassuring experience.
2025-12-29 12:29:22 +01:00
lebaudantoine 4345711771 (frontend) remove the beta badge
Product is out of beta since the 15th of December.
2025-12-29 12:29:22 +01:00
lebaudantoine 7c690c369e ♻️(agents) remove deprecation warning for RoomInput/OutputOptions
Follow LiveKit's recommendations.
2025-12-28 22:34:38 +01:00
lebaudantoine ef09629566 ⬆️(agent) upgrade temporary livekit-agent plugin for kyutai
0.0.5 was ignoring the API key environment variable. I fixed it.
2025-12-28 22:34:38 +01:00
lebaudantoine cff1dbf39e ♻️(agent) simplify Deepgram config and support Kyutai
The previous attempt to make the Deepgram configuration extensible
introduced unnecessary complexity for a very limited use case and
made it harder to add new STT backends.

Refactor to a deliberately simple and explicit design with minimal
cognitive overhead. Configuration is now fully driven by environment
variables and provides enough flexibility for ops to select and
parameterize the STT backend.
2025-12-28 21:14:20 +01:00
lebaudantoine b466515306 (agent) add a temporary livekit-agent plugin for kyutai
Until a Pull Request is merged with our changes on livekit-agent
to support Kyutai API, we will use a custom and hacky python
library made from Arnaud's researches and published on an
unofficial pypi project page.

Everything is quite "draft" but it allows us to deploy and test
in real situation the work from Arnaud.
2025-12-28 21:14:20 +01:00
lebaudantoine c678e9420e ⬆️(agent) upgrade livekit-agent related dependencies
Our custom LaSuite Kyutai plugin requires livekit-agent above 1.3.3.
2025-12-28 21:14:20 +01:00
lebaudantoine 3af115dafb 🐛(agent) restore missing system deps in Docker image
Some system dependencies were unexpectedly missing, causing the
LiveKit agent framework to fail at runtime.

Install the required dependencies based on runtime error logs.
This fixes Docker image failures in the remote (staging) environment.
2025-12-28 21:14:20 +01:00
lebaudantoine 0daa6d0432 🔖(release) release 1.1.0
- enable user provisioning through the external viewset
- add LLM observability on the summary service
2025-12-22 11:23:28 +01:00
lebaudantoine 493d7b96f1 📝(docs) add missing trailing slash
A trailing slash was missing in the documentation.
Spotted by T. Lemeur when integrating the API.
2025-12-22 09:57:34 +01:00
lebaudantoine c2c478c367 🩹(backend) remove environment prefix from recently introduced settings
The prefix was unintentionally added and wasn’t caught during review.
This change corrects it.
2025-12-21 16:27:11 +01:00
lebaudantoine b5895ccba0 🩹(summary) fix missing f-string
Spotted by code rabbit. Missing F-string was leading
to an unexpected behavior.
2025-12-19 14:29:56 +01:00
lebaudantoine aff87d4953 (summary) add Langfuse observability for LLM API calls
Implement Langfuse tracing integration for LLM service calls to capture
prompts, responses, latency, token usage, and errors, enabling
comprehensive monitoring and debugging of AI model interactions
for performance analysis and cost optimization.
2025-12-19 14:29:56 +01:00
lebaudantoine c81ef38005 ♻️(summary) extract LLMService class into dedicated module
Move LLMService class from existing file into separate dedicated
module to improve code organization.
2025-12-19 14:29:56 +01:00
lebaudantoine 4256eb403d 🔒️(summary) refactor configuration secrets to use Pydantic SecretStr
Replace plain string fields with Pydantic SecretStr class for all
sensitive configuration values in FastAPI settings to prevent accidental
exposure in logs, error messages, or debugging output, following
security best practices for credential handling.
2025-12-19 14:29:56 +01:00
lebaudantoine 43f3e4691b (summmary) add Langfuse to summary service dependencies
Install Langfuse observability client in summary service
to enable LLM tracing, monitoring, and debugging capabilities
for AI-powered summarization workflows,
improving visibility into model performance and behavior.
2025-12-19 14:29:56 +01:00
lebaudantoine 10aac93c36 📝(backend) improve user provisioning documentation
try to make explicit all implicit implementation's details
2025-12-19 13:41:37 +01:00
lebaudantoine 4e6bc157b0 ♻️(backend) standardize error response format in token endpoint
Align error response with the pattern used at other places of the codebase.
2025-12-19 13:41:37 +01:00
lebaudantoine fe83c5fa07 (backend) add unit tests for user provisioning via external API
Add test coverage for provisional user creation through the external API,
including creating users with email-only (no sub)
2025-12-19 13:41:37 +01:00
lebaudantoine 827014c952 ♻️(backend) explicitly enforce sub field immutability
Add OIDC_USER_SUB_FIELD_IMMUTABLE setting to our config and enforce
it in the user viewset. Previously relied on implicit Django
LaSuite defaults.

Makes the sub mutability constraint explicit and ensures it's enforced
at the application level, critical for provisional users where sub is
assigned on first login.
2025-12-19 13:41:37 +01:00
lebaudantoine 9523f52546 📝(docs) clarify sub as optional to support email-only user provisioning
Update the sub field documentation to explicitly reflect its optional nature.
Originally intended to be mandatory, sub became optional due to a code issue.
This change acknowledges and formalizes that behavior as intentional.

The optional sub enables external API integrations to provision users with
only an email address. Full identity (sub) is assigned on first login,
allowing third-party platforms to create users before they authenticate.
2025-12-19 13:41:37 +01:00
lebaudantoine 8348a55f7e (backend) enable user creation via email for external integrations
Allow external platforms using the public API to create provisional users
with email-only identification when the user doesn't yet exist in our
system. This removes a key friction point blocking third-party integrations
from fully provisioning access on behalf of new users.

Provisional users are created with email as the primary identifier. Full
identity reconciliation (sub assignment) occurs on first login, ensuring
reliable user identification is eventually established.

While email-only user creation is not ideal from an identity perspective,
it provides a pragmatic path to unlock integrations and accelerate adoption
through external platforms that are increasingly driving our videoconference
tool's growth.
2025-12-19 13:41:37 +01:00
lebaudantoine a4b76433ab 🧑‍💻(release) introduce a release helper tool
Discussed at lunch with our CTO, enhance tooling
around release preparation. Naive bash script generated
using Claude. Please feel free to enhance it.
2025-12-17 19:55:24 +01:00
lebaudantoine ae863418cd 📝(changelog) reorganize sections to match Keep a Changelog convention
Reorder CHANGELOG section headings to follow standard Keep a Changelog format
(Added, Changed, Deprecated, Removed, Fixed, Security) for consistent structure
that users expect when reviewing release notes.
2025-12-17 18:41:45 +01:00
lebaudantoine dcdae26610 🔖(release) release 1.0.1
Patch several accessibility issues.
2025-12-17 17:36:01 +01:00
Cyril 90c0442d35 (frontend) fix focus scroll jump during side panel animation
preventScroll avoids layout shift that broke the slide-in chat animation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:25 +01:00
Cyril 9093371d25 (frontend) restore focus on chat close
restore keyboard focus to the triggering element when the chat panel closes.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:24 +01:00
Cyril 1d45d3aa7c (frontend) focus chat input on panel open
move keyboard focus to the message input when the chat panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:12:44 +01:00
Cyril fcb89c520e ️(frontend) fix heading level in modal to maintain semantic hierarchy
replaced h3 with h2 for accessibility and proper document structure
2025-12-17 16:00:35 +01:00
Cyril 309ce0989d ️(frontend) indicate external link opens in new window on feedback
added title attribute to clarify link behavior for screen reader users
2025-12-17 15:42:30 +01:00
Cyril a6c154374f ️(frontend) change ptt keybinding from space to v
ptt now uses v key to avoid accidental activation when typing
2025-12-17 15:18:46 +01:00
lebaudantoine b0e27b38e2 🔒️(backend) avoid serializing rooms's pin code when restricted
Prevent anonymous users waiting in the lobby, or attacker
to discover the room pin code, that would allow them to join a room.
2025-12-17 10:05:23 +01:00
Cyril 9bdc68f9c9 (frontend) create reusable shortcut tooltip component
extracted tooltip into a component to unify style and ease reuse across ui

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:43 +01:00
Cyril 4545e9fa1e 💄(frontend) update shortcut tooltip position and style for consistency
moved tooltip from left to right to avoid overlap with recording indicator
2025-12-16 09:41:43 +01:00
Cyril 3f1edbf134 ️(frontend) fix SR texts/translations to avoid double announcement
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:42 +01:00
Cyril 4f2764eef4 ️(frontend) add tooltip and sr hint for f2 shortcut to bottom toolbar
helps keyboard and sr users discover the f2 shortcut for toolbar access

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:51 +01:00
Cyril b11cc6e9da ️(frontend) update blur and focus translations for participants
adds fr/en/de/nl translations for blur and focus accessibility labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:40 +01:00
Cyril 0a7eb97c90 ️(frontend) hide avatar initials from sr to avoid duplicate names
prevents screen readers from announcing participant names twice

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:26 +01:00
Cyril db188075af ️(frontend) improve meeting a11y: blur, focus, hover, sr announcements
enhances keyboard nav and screen reader support for meeting interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:56:58 +01:00
lebaudantoine 98e568d63c 🔖(major) release 1.0.0
Wouhou, finally. Important milestone, as our software is used by
thousand of users in production.
2025-12-11 00:18:59 +01:00
lebaudantoine 97e1f7f53f 🔥(changelog) remove outdated unreleased entries from CHANGELOG
Clean up CHANGELOG by removing old unreleased changes that are
no longer relevant or superseded by subsequent work.
2025-12-11 00:18:59 +01:00
lebaudantoine 6022809888 👷(ci) add CI check for CHANGELOG updates in pull requests
Implement automated CI validation ensuring pull request authors
update CHANGELOG with their changes, preventing undocumented
changes from merging and maintaining accurate release
documentation for users and maintainers.
2025-12-11 00:18:59 +01:00
lebaudantoine d241de6af1 🔖(minor) bump release to 0.1.43
- upgrade dependencies for security reason
- handle hallucination in transcription
- minor frontend fixes
- support resource server authentification
2025-12-10 23:16:22 +01:00
Martin Guitteny ad494f5de5 ♻️(summary) refactor transcript formatting into unified handler class
Consolidate scattered transcript formatting functions into single
cohesive class encapsulating all transcript processing logic
for better maintainability and clearer separation of concerns.

Add transcript cleaning step to remove spurious recognition artifacts
like randomly predicted "Vap'n'Roll Thierry" phrases that appear
without corresponding audio, improving transcript quality
by filtering model hallucinations.
2025-12-10 20:40:23 +01:00
lebaudantoine fba879e739 (backend) allow prefixing resource server scopes
When declaring scopes with our OIDC provider, they require us to prefix
each scope with our application name. This is to prevent reserving generic
scopes like rooms:list for only our app, as they manage a large federation.

I’m proposing a workaround where, if a resource server prefix is detected in
the scope, it’s stripped out. This solution is simple and sufficient
in my opinion.

Since the scopes are defined in the database, I don’t want to update
them directly. Additionally, each self-hosted instance may have a different
application name, so the prefix should be configurable via a Django setting.
2025-12-10 19:47:36 +01:00
renovate[bot] cac5595a91 ⬆️(dependencies) update vite to v7.0.8 [SECURITY] 2025-12-10 17:25:55 +01:00
lebaudantoine 78e5c72310 🐛(frontend) prevent invite dialog to show up on mobile
While creating a meeting on mobile, the dialog was opening,
and when its width exceeds the mobile width, users are unable
to close the dialog.

Prevent the dialog opening on mobile as a hot fix.
2025-12-10 12:52:41 +01:00
lebaudantoine 2ab31189f4 🐛(frontend) fix unclickable fullscreen warning buttons
Adjust z-index values to restore button interactivity broken by previous
z-index changes in commit 53e68b7, ensuring fullscreen warning dismiss
controls remain accessible to users instead of being blocked
by overlay layering.
2025-12-10 12:48:24 +01:00
lebaudantoine bb4a863f8d ⬆️(frontend) manually upgrade Alpine dependencies to fix libpng vul
Manually update libexpat to 1.6.53-r0 in Alpine 3.21.3 base image
to address CVE-2025-64720, CVE-2025-65018,
CVE-2025-66293 high-severity vulnerability until newer Alpine base image
becomes available, ensuring Trivy security scans pass.
2025-12-10 12:43:19 +01:00
renovate[bot] 0241f67787 ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-09 22:18:27 +01:00
lebaudantoine 908bbb828a 📝(backend) add resource server quickstart documentation
Create initial resource server integration documentation based on existing
service account documentation structure to help developers understand
authentication flow and implementation requirements for external services
consuming Meet's protected resources.
2025-11-24 19:50:12 +01:00
lebaudantoine c7f5dabbad (backend) integrate ResourceServerAuthentication on the external api
Upgrade django-lasuite to v0.0.19 to benefit from the latest resource server
authentication backend. Thanks @qbey for your work. For my needs, @qbey
refactored the class in #46 on django-lasuite.

Integrate ResourceServerAuthentication in the relevant viewset. The integration
is straightforward since most heavy lifting was done in the external-api viewset
when introducing the service account.

Slightly modify the existing service account authentication backend to defer to
ResourceServerAuthentication if a token is not recognized.

Override user provisioning behavior in ResourceServerBackend: now, a user is
automatically created if missing, based on the 'sub' claim (email is not yet
present in the introspection response). Note: shared/common implementation
currently only retrieves users, failing if the user does not exist.
2025-11-24 18:23:38 +01:00
lebaudantoine a642c6d9a2 🔧(backend) add Docker network for shared Keycloak OIDC authentication
Define Docker network enabling external service providers to share Keycloak
instance with local development stack, supporting OIDC authentication flow
where services obtain tokens from shared Keycloak then pass to Meet
for introspection and validation.

Prepares Meet infrastructure for multi-service authentication architecture
though external service provider Docker Compose integration changes remain
in separate repository.
2025-11-24 18:23:38 +01:00
lebaudantoine a6dc12d91c 🩹(frontend) avoid unnecessary redirection while authenticating
A manually constructed authentication URL didn’t match the actual endpoint
address, causing the Django backend to issue a 301 redirect to the correct URL.

This wasn’t a problem for regular users at first, but once a client integrating
through a virtual browser came on board, it became significant. The 301 redirect
was disrupting the virtual browser’s cookie/cache system, which in turn broke
the authentication flow.

This change aims to resolve the issue, although it’s not yet certain that
it will fully address their problem.
2025-11-20 10:10:03 +01:00
lebaudantoine 307987d94d 🌐(backend) compile missing translations
I forgot to compile newly added backend translations.
Fix it.
2025-11-15 16:31:07 +01:00
lebaudantoine d7ebdbf401 🔖(minor) bump release to 0.1.42
- add admin action to retry a recording notification to external services
- log more Celery tasks' parameters
- add multilingual support for real-time subtitles
- update backend dependencies
2025-11-14 18:23:22 +01:00
lebaudantoine dad396273c ️(frontend) hide decorative icons from screen readers per issue #730
Mark unnecessary decorative icons as aria-hidden following feedback
from @cyberbaloo to eliminate redundant screen reader announcements
that create noisy and annoying experience for users relying on
assistive technologies.
2025-11-13 18:23:49 +01:00
lebaudantoine 555daedeba 🌐(backend) update translation files with newly introduced strings
Regenerate backend translation files to include missing translations for newly
added translatable strings in recent code changes, ensuring complete
internationalization coverage across all supported languages.
2025-11-13 18:02:49 +01:00
lebaudantoine 0d09d1df08 (backend) fix auth unit test with django-lasuite 0.1.16 update
django-lasuite 0.1.16 changed the user update mechanism from .update()
to .save(), which triggers Django's constraint validation. This causes
an additional SELECT query to verify 'sub' field uniqueness on every
user update, despite 'sub' being immutable in our auth flow.

This commit update the test to make them pass again.
2025-11-13 16:26:17 +01:00
lebaudantoine a40af726b6 📌(backend) pin pylint to 3.x to resolve compatibility conflict
Restrict pylint version to 3.x in renovate configuration because
pylint-django 2.6.1 requires pylint<4, preventing automatic upgrades
to pylint 4.x that would create unresolvable dependency conflicts
until pylint-django releases compatible version.
2025-11-13 16:26:17 +01:00
renovate[bot] f8a37e55b1 ⬆️(dependencies) update python dependencies 2025-11-13 16:26:17 +01:00
lebaudantoine 3baec0a863 ⬆️(backend) upgrade brotli to 1.2.0 to fix CVE-2025-6176
Update brotli compression library to version 1.2.0 addressing
CVE-2025-6176 security vulnerability to maintain secure
compression functionality and pass security scans.
2025-11-13 10:28:10 +01:00
lebaudantoine 5b6ed6bbf0 ⬆️(backend) upgrade Django to 5.2.8 to fix security vulnerabilities
Update Django from previous version to 5.2.8 addressing CVE-2025-64459
and CVE-2025-64458 security vulnerabilities to maintain secure
application infrastructure and pass security audits.
2025-11-13 10:28:10 +01:00
anonymous candidate aea01636cf 👷(ci) use variables in pipeline for docker registry
Introduce new variables for the docker registry where to push docker images on forks:
- DOCKER_CONTAINER_REGISTRY_HOSTNAME for the docker registry hostname, with default value "docker.io"
- DOCKER_CONTAINER_REGISTRY_NAMESPACE for the docker registry namespace, with default value "lasuite"
2025-11-13 09:43:16 +01:00
unteem e4c2b42e4a 📝(self-hosted) add documentation for self-hosting on docker compose
It describes the minimalist LaSuite Meet instance, with the simple
feature of having a room conference.
2025-11-13 09:38:47 +01:00
unteem 36ba0f9c8e 📝(self-hosted) reorganize doc for new installation exmaples
We will introduce in the next commits the compose set-up that also
require examples values/config files. Thus, re-organize the kube ones
to  dedicated folder, to make the files organisation extensible.
2025-11-13 09:38:47 +01:00
Ghislain LE MEUR 2d6fe6ee7d 🔖(helm) release chart 0.0.15
This release adds support for injecting custom Kubernetes
resources through the extraManifests parameter.

New features:
- Add extraManifests support for deploying custom resources
- Support multiple input formats (list, map, raw YAML strings)
- Enable Helm template variables in injected manifests
2025-11-12 14:38:20 +01:00
Ghislain LE MEUR e2fcf7dd2c (helm) add extraManifests support for custom resources
Add ability to inject custom Kubernetes manifests through the
values.yaml file. This allows users to deploy additional
resources (Deployments, Services, ConfigMaps, etc.) without
modifying the chart templates.

The template supports multiple input formats: list of objects,
map of named objects, and raw YAML strings, providing maximum
flexibility for users.

- Create templates/extra-objects.yaml with flexible rendering
- Add extraManifests parameter in values.yaml with documentation
- Support Helm template variables in injected manifests
- Handle list, map, and string YAML formats automatically
2025-11-12 14:38:20 +01:00
Ghislain LE MEUR 9f9cef7e2a (agents) add multilingual support for real-time subtitles
Add dynamic configuration for Deepgram STT via environment variables,
enabling multilingual real-time subtitles with automatic language
detection.

Changes:
- Add DEEPGRAM_STT_* environment variables pattern for configuration
- Implement _build_deepgram_stt_kwargs() to dynamically build STT
  parameters from environment variables
- Add whitelist of supported parameters (model, language) for LiveKit
  Deepgram plugin
- Log warnings for unsupported parameters (diarize, smart_format, etc)
- Set default configuration: model=nova-3, language=multi
- Document supported parameters in Helm values.yaml

Configuration:
- DEEPGRAM_STT_MODEL: Deepgram model (default: nova-3)
- DEEPGRAM_STT_LANGUAGE: Language or 'multi' for automatic detection
  of 10 languages (en, es, fr, de, hi, ru, pt, ja, it, nl)

Note: Advanced features like diarization and smart_format are not
supported by the LiveKit Deepgram plugin in streaming mode.
2025-11-12 11:45:08 +01:00
lebaudantoine b403ac56bf 🚨(summary) disable linter warning too many statements
summarize_transcribe_v2 as now slightly too many statements,
ignore it for now, but I'll reorganize the code asap.
2025-10-23 06:39:12 +02:00
lebaudantoine baf378d53d (backend) add the owner column to the Room Admin view
Enable administrators to easily identify the owners of a room
when possible. Save one precious click and time.
2025-10-23 06:39:12 +02:00
lebaudantoine 990507e3c7 🔊(summary) increase transcription Celery task logging verbosity
Add detailed logging for owner ID, recording metadata, and
processing context in transcription tasks to improve debugging
capabilities.

It was especially important to get the created document id,
so when having trouble with the docs API, I could share
with them the newly created documents being impacted.
2025-10-23 06:39:12 +02:00
lebaudantoine 6cd54f7e1e 🐛(backend) catch all request exceptions in summary service integration
Replace narrow HTTPError handling with broad RequestException
catch to prevent crashes from network failures (ConnectionError),
timeouts (30s exceeded), SSL/TLS errors, and other request failures
that previously caused unhandled exceptions.

Ensures consistent False return and proper logging for all network-related
failures instead of crashing application when summary service
communication encounters infrastructure issues beyond HTTP errors.
2025-10-23 06:39:12 +02:00
lebaudantoine 315d48a501 (backend) add recording mode column to the list display
While helping users, it was such a pain to determine quickly which recording
was indeed a transcription or a video recording.

Added the column to help me, and support team.
The recording / transcription is the most unstable part of the project.
2025-10-23 06:39:12 +02:00
lebaudantoine 2f7b56f918 (backend) add admin action to manually retrigger notifications
Enable administrators to manually retrigger external service notifications
from Django admin for failed or missed notification scenarios,
providing operational control over notification delivery.
2025-10-23 06:39:12 +02:00
lebaudantoine 53e68b7780 🐛(frontend) remove excessive z-index from screenshare warning overlay
Remove 1000 z-index from screenshare warning that was
causing conflicts with reaction menu and reaction displays,
retaining only necessary layering to hide participant
metadata underneath.
2025-10-22 12:00:40 +02:00
lebaudantoine 10eda5c2ea 🔖(minor) bump release to 0.1.41
- fix transcription observability
- introduce auto idle disconnection
2025-10-22 11:04:04 +02:00
lebaudantoine ba3b3fe0ba (frontend) add localStorage persistence for user preference settings
Persist user preference choices across sessions using localStorage
following notification store pattern, eliminating need to reconfigure
disabled features on every meeting join and respecting user's
long-term preference decisions.
2025-10-22 10:04:47 +02:00
lebaudantoine 0c3bcd81c9 ♻️(frontend) refactor notification preferences to use Field switch
Adopt unified switch component pattern for notification preferences to
enable future addition of descriptive text per notification type,
improving consistency and providing clearer explanation capability
for notification behaviors.
2025-10-22 10:04:47 +02:00
lebaudantoine dbc66c2f07 (frontend) add user setting to disable idle disconnect feature
Allow users to opt-out of idle participant disconnection despite
default enforcement, trusting power users who modify this setting
won't forget to disconnect, though accepting risk they may block
maintenance configuration updates.
2025-10-22 10:04:47 +02:00
lebaudantoine 39be4697b0 💄(frontend) add right margin to switch description for better spacing
Add margin between switch description text and toggle button to
improve visual breathing room and prevent text from appearing
cramped against interactive control element.
2025-10-22 10:04:47 +02:00
lebaudantoine 2443fa63a5 (frontend) add idle disconnect warning dialog for LiveKit maintenance
Introduce pop-in alerting participants of automatic 2-minute idle
disconnect to enable LiveKit node configuration updates during
maintenance windows, preventing forgotten tabs from blocking
overnight production updates following patterns
from proprietary videoconference solutions.
2025-10-22 10:04:47 +02:00
lebaudantoine 214dc87b1f (frontend) add narrow "alert" dialog mode for concise messages
Introduce new narrow-width alert dialog variant to improve
readability of short messages by preventing excessively
long line lengths that occur when brief alerts use
standard dialog widths.
2025-10-22 10:04:47 +02:00
lebaudantoine 3dc23be101 (backend) add configuration for idle disconnect timeout
Expose idle disconnect timeout as configurable parameter accepting None value
to disable feature entirely, providing emergency killswitch for buggy behavior
without redeployment, following other frontend configuration patterns.
2025-10-22 10:04:47 +02:00
lebaudantoine 6b5e8081bc 🐛(celery) fix metadata task_args order broken by signal sender argument
Restore correct task_args ordering in metadata manager after commit f0939b6f
added sender argument to Celery signals for transcription task scoping,
unexpectedly shifting positional arguments and breaking metadata creation.

Issue went undetected due to missing staging analytics deployment, silently
losing production observability on microservice without blocking transcription
job execution, highlighting need for staging analytics activation.
2025-10-22 07:17:00 +02:00
lebaudantoine df671ea994 🐛(frontend) posthog-cli 0.5.0 release introduced breaking changes
Posthog-cli version wasn't pinned.
Please check issue #39846, which describe our issue, starting
0.5.0, the cli needs an API token and a Project ID.

Pin to the last stable version we used 0.4.8, and wait a bit
they already released a 0.5.1 that mitigate some of the breaking
change.

I would wait the 0.5.x to be stable and battle tested by other
developpers before switching.

Also as I consider switching the Error tracking to sentry.
2025-10-22 05:48:06 +02:00
lebaudantoine 06a5b9b17e 🩹(doc) fix wrong endpoint path
Applications to application in the application/token endpoint.
Spotted by external contributor.
2025-10-22 05:07:02 +02:00
Ghislain LE MEUR 59d4c2583b 🐛(auth) fix LiveKit token authentication field mismatch
Fixes "Invalid LiveKit token" errors caused by field mismatch between
token generation and authentication lookup.

Previously:
- generate_token() used user.sub as token identity
- LiveKitTokenAuthentication tried to retrieve user via user.id field
- This failed when sub was not a UUID (e.g., from LemonLDAP OIDC provider)

Now:
- generate_token() continues using user.sub (canonical OIDC identifier)
- LiveKitTokenAuthentication correctly looks up by sub field
- Both sides now consistently use the same field

This ensures compatibility with all RFC 7519-compliant OIDC providers,
regardless of their sub claim format.
2025-10-20 04:57:02 +02:00
Ghislain LE MEUR 4b80b4ac9f 🔖(helm) release chart 0.0.14
Fix missing image and command attributes for celery workers
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR 96d7a8875b 🐛(helm) add default commands for celery workers
Without explicit commands in values.yaml,
celeryTranscribe and  celerySummarize pods
were using the Dockerfile's default CMD (uvicorn),
which started the REST API instead of Celery workers.

This fix adds default commands to values.yaml for both services,
ensuring they run as Celery workers processing their respective
queues (transcribe-queue and summarize-queue).
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR dc177b69d8 🐛(summary) add image
Add missing image attributes for summary, celerySummarize and celeryTranscribe
2025-10-17 12:18:31 +02:00
Martin Guitteny 36b2156c7b ️(summary) change formating from prompt to response_format
Add ability to use response_format in call function in order to
have better result with albert-large model
Use reponse_format for next steps and plan generation
2025-10-13 12:07:54 +02:00
lebaudantoine ec94d613fa 🔖(minor) bump release to 0.1.40
- enhance technical documentation
- introduce external-api and service account
- fix inverted keyboard shortcuts
- allow configuring whisperX language (still wip)
- filter livekit event when sharing a single livekit instance
2025-10-12 17:13:09 +02:00
lebaudantoine 70d9d55227 🔖(helm) release chart 0.0.13
This chart exposes an external API from the backend pod.
Currently, it does not include conditional addition of the external API route.
This functionality will be added later.
2025-10-12 17:05:58 +02:00
lebaudantoine 5c74ace0d8 🐛(backend) filter LiveKit events by room name regex to exclude Tchap
Add configurable room name regex filtering to exclude Tchap events from shared
LiveKit server webhooks, preventing backend spam from unrelated application
events while maintaining UUID-based room processing for visio.

Those unrelated application events are spamming the sentry.

Acknowledges this is a pragmatic solution trading proper namespace
prefixing for immediate spam reduction with minimal refactoring impact
leaving prefix-based approach for future improvement.
2025-10-12 16:57:44 +02:00
lebaudantoine f0939b6f7c 🐛(summary) scope metadata manager signals to transcription tasks only
Restrict metadata manager signal triggers to transcription-specific Celery
tasks to prevent exceptions when new summary worker executes tasks
not designed for metadata operations, reducing false-positive Sentry errors.
2025-10-10 14:00:00 +02:00
lebaudantoine aecc48f928 🔧(summary) add configurable language settings for WhisperX transcription
Make WhisperX language detection configurable through FastAPI settings
to handle empty audio start scenarios where automatic detection fails and
incorrectly defaults to English despite 99% French usage.

Quick fix acknowledging long-term solution should allow dynamic
per-recording language selection configured by users through web
interface rather than global server settings.
2025-10-10 13:55:53 +02:00
lebaudantoine 4353db4a5f 🐛(frontend) fix inverted keyboard shortcuts for video and microphone
Correct accidentally swapped keyboard shortcuts between video and
microphone toggle controls introduced during device component
refactoring, restoring expected shortcut behavior reported by users.
2025-10-09 22:44:14 +02:00
Martin Guitteny 469e824167 ♻️(devexp) refactor minio webhook setup
Instead of relying on make commands to set-up the minio webhook,
use a compose service, as we did for the createbucket one.

Aligned with the dev stack, and run by default when starting
for the first time the stack.
2025-10-07 21:12:06 +02:00
lebaudantoine 4c6741c905 🔧(backend) add Django setting to disable external API endpoints
Introduce ENABLE_EXTERNAL_API setting (defaults to False) to allow
administrators to disable external API endpoints, preventing unintended
exposure for self-hosted instances where such endpoints aren't
needed or desired.
2025-10-06 19:34:24 +02:00
lebaudantoine 69a9a07d21 📝(backend) add Swagger documentation for external API
Document the external API using a simple Swagger file that can be opened
in any Swagger editor.

The content was mostly generated with the help of an LLM and has been human-
reviewed. Corrections or enhancements to the documentation are welcome.

Currently, my professional email address is included as a contact. A support
email will be added later once available. The documentation will also be
expanded as additional endpoints are added.
2025-10-06 19:34:24 +02:00
lebaudantoine c9fcc2ed60 (backend) draft initial Room viewset for external applications
From a security perspective, the list endpoint should be limited to return only
rooms created by the external application. Currently, there is a risk of
exposing public rooms through this endpoint.

I will address this in upcoming commits by updating the room model to track
the source of generation. This will also provide useful information
for analytics.

The API viewset was largely copied and adapted. The serializer was heavily
restricted to return a response more appropriate for external applications,
providing ready-to-use information for their users
(for example, a clickable link).

I plan to extend the room information further, potentially aligning it with the
Google Meet API format. This first draft serves as a solid foundation.

Although scopes for delete and update exist, these methods have not yet been
implemented in the viewset. They will be added in future commits.
2025-10-06 19:34:24 +02:00
lebaudantoine b8c3c3df3a (backend) add minimal scope control for external API JWTs
Enforce the principle of least privilege by granting viewset permissions only
based on the scopes included in the token.

JWTs should never be issued without controlling which actions the application
is allowed to perform.

The first and minimal scope is to allow creating a room link. Additional actions
on the viewset will only be considered after this baseline scope is in place.
2025-10-06 19:34:24 +02:00
lebaudantoine 1f3d0f9239 (backend) add delegation mechanism to external app /token endpoint
This endpoint does not strictly follow the OAuth2 Machine-to-Machine
specification, as we introduce the concept of user delegation (instead of
using the term impersonation).

Typically, OAuth2 M2M is used only to authenticate a machine in server-to-server
exchanges. In our case, we require external applications to act on behalf of a
user in order to assign room ownership and access.

Since these external applications are not integrated with our authorization
server, a workaround was necessary. We treat the delegated user’s email as a
form of scope and issue a JWT to the application if it is authorized to request
it.

Using the term scope for an email may be confusing, but it remains consistent
with OAuth2 vocabulary and allows for future extension, such as supporting a
proper M2M process without any user delegation.

It is important not to confuse the scope in the request body with the scope in
the generated JWT. The request scope refers to the delegated email, while the
JWT scope defines what actions the external application can perform on our
viewset, matching Django’s viewset method naming.

The viewset currently contains a significant amount of logic. I did not find
a clean way to split it without reducing maintainability, but this can be
reconsidered in the future.

Error messages are intentionally vague to avoid exposing sensitive
information to attackers.
2025-10-06 19:34:24 +02:00
lebaudantoine 062afc5b44 (backend) introduce an external API router
Prepare for the introduction of new endpoints reserved for external
applications. Configure the required router and update the Helm chart to ensure
that the Kubernetes ingress properly routes traffic to these new endpoints.

It is important to support independent versioning of both APIs.
Base route’s name aligns with PR #195 on lasuite/drive, opened by @lunika
2025-10-06 19:34:24 +02:00
lebaudantoine 3fd5a4404c (backend) add application model with secure secret handling
We need to integrate with external applications. Objective: enable them to
securely generate room links with proper ownership attribution.

Proposed solution: Following the OAuth2 Machine-to-Machine specification,
we expose an endpoint allowing external applications to exchange a client_id
and client_secret pair for a JWT. This JWT is valid only within a well-scoped,
isolated external API, served through a dedicated viewset.

This commit introduces a model to persist application records in the database.
The main challenge lies in generating a secure client_secret and ensuring
it is properly stored.

The restframework-apikey dependency was discarded, as its approach diverges
significantly from OAuth2. Instead, inspiration was taken from oauthlib and
django-oauth-toolkit. However, their implementations proved either too heavy or
not entirely suitable for the intended use case. To avoid pulling in large
dependencies for minimal utility, the necessary components were selectively
copied, adapted, and improved.

A generic SecretField was introduced, designed for reuse and potentially
suitable for upstream contribution to Django.

Secrets are exposed only once at object creation time in the Django admin.
Once the object is saved, the secret is immediately hashed, ensuring it can
never be retrieved again.

One limitation remains: enforcing client_id and client_secret as read-only
during edits. At object creation, marking them read-only excluded them from
the Django form, which unintentionally regenerated new values.
This area requires further refinement.

The design prioritizes configurability while adhering to the principle of least
privilege. By default, new applications are created without any assigned scopes,
preventing them from performing actions on the API until explicitly configured.

If no domain is specified, domain delegation is not applied, allowing tokens
to be issued for any email domain.
2025-10-06 19:34:24 +02:00
Martin Guitteny c07b8f920f 📝(docs) add summarization documentation
Add documentation for transcription et summarization
Include sequence diagrams
2025-10-06 14:53:56 +02:00
lebaudantoine a25baa628a 📝(docs) document calendar integrations as under construction
Add documentation noting calendar integrations is currently
under active development.
2025-10-06 13:08:46 +02:00
lebaudantoine ad084e2e52 📝(docs) document signaling configuration and related env vars
Add detailed documentation on signaling server configuration
and associated environment variables to help administrators properly
configure WebRTC connection establishment.
2025-10-06 13:08:46 +02:00
lebaudantoine dedac9106c 📝(docs) document subtitle feature as under construction
Add documentation noting subtitle functionality is currently under
active development to set appropriate expectations for administrators
and prevent deployment assumptions about feature maturity.
2025-10-06 13:08:46 +02:00
lebaudantoine cbea1c0c01 📝(docs) document telephony feature and component interactions
Add comprehensive telephony documentation explaining system requirements
and component interactions to help administrators understand infrastructure
needs.
2025-10-06 13:08:46 +02:00
lebaudantoine a92633a4bb 📝(docs) document recording feature architecture and interactions
Add comprehensive recording documentation explaining system requirements
and component interactions to help administrators understand infrastructure
needs and troubleshoot recording functionality.
2025-10-06 13:08:46 +02:00
lebaudantoine 7f8fad42cb 📝(docs) document authentication configuration and supported methods
Expand authentication documentation to clarify supported authentication
mechanisms and their configuration nuances, helping administrators
understand different authentication flows and choose appropriate methods
for their deployment security requirements.
2025-10-06 13:08:46 +02:00
lebaudantoine fab046a729 📝(frontend) document application theming with different approaches
Add initial theming documentation covering both runtime customization and
build-time configuration methods to help self-hosters adapt the
application's visual identity to their organizational branding needs.
2025-10-06 13:08:46 +02:00
lebaudantoine 6bb22ae6f1 📝(docs) enhance installation documentation for Docker Compose deployment
Improve installation instructions to prepare for comprehensive Docker
Compose documentation launch, clarifying setup steps and addressing
common deployment questions to reduce onboarding friction.
2025-10-06 13:08:46 +02:00
lebaudantoine 8f72769dff 📝(frontend) update README with docs inspired content
Enhance README by incorporating content from LaSuite Docs, adding
comprehensive list of other LaSuite Meet instances, and refining
presentation details to improve project discoverability and onboarding.
2025-10-06 13:08:46 +02:00
lebaudantoine fa1feceb8b 🔥(docs) remove outdated legacy release documentation
Delete deprecated internal release process documentation that no longer
applies to current deployment practices, eliminating confusion from
obsolete workflow references.
2025-10-06 13:08:46 +02:00
lebaudantoine 57aa812ef6 🔖(minor) bump release to 0.1.39
Enable meeting summary (/w a feature flag)
2025-10-06 11:28:12 +02:00
lebaudantoine c36d99b855 ⬆️(backend) upgrade django to 5.2.7
Resolve vulnerability CVE-2025-59681, that triggers Trivy scan
and block PR's merging.

More information there https://avd.aquasec.com/nvd/cve-2025-59681
2025-10-06 10:52:44 +02:00
lebaudantoine c83d3b99fc 💡(summary) improve metadata manager error messages with explicit source
Enhance error logging in metadata manager to explicitly identify
the metadata manager as error source.
2025-10-01 15:32:27 +02:00
lebaudantoine a58d3416e0 🐛(summary) fix metadata manager args after adding owner_id parameter
Update metadata manager initialization with additional required arguments
after owner_id field addition broke existing initialization logic, restoring
proper metadata handling functionality in summary microservice.
2025-10-01 15:32:27 +02:00
Martin Guitteny c3eb877377 🐛(summary) fix feature flag on summary job
Sadly, we used user db id as the posthog distinct id
of identified user, and not the sub.

Before this commit, we were only passing sub to the
summary microservice.

Add the owner's id. Please note we introduce a different
naming behavir, by prefixing the id with "owner". We didn't
for the sub and the email.

We cannot align sub and email with this new naming approach,
because external contributors have already started building
their own microservice.
2025-09-30 22:46:30 +02:00
lebaudantoine 9cb9998384 ⬆️(frontend) manually upgrade Alpine dependencies to fix libexpat vul
Manually update libexpat to 2.7.2-r0 in Alpine 3.21.3 base image
to address CVE-2025-59375 high-severity vulnerability until newer
Alpine base image becomes available, ensuring Trivy security scans pass.
2025-09-30 15:14:51 +02:00
lebaudantoine a3ca6f0113 📈(frontend) track more room events in PostHog for disconnections
Add additional room event tracking to PostHog analytics to better
understand and diagnose disconnection error patterns. Enhanced
telemetry will provide insights for improving connection stability.
2025-09-18 23:47:13 +02:00
lebaudantoine 1d9caeb17f 🐛(helm) fix broken worker assignment due to extra space
Remove incorrect whitespace in queue names that prevented Celery
workers from listening to proper queues. Workers were attempting to
connect to non-existent queues, breaking task distribution.
2025-09-18 18:27:10 +02:00
lebaudantoine 5caed6222b 🐛(summary) fix transcribe job queue assignment
Ensure transcribe jobs are properly assigned to their specific queue
instead of using default queue. This prevents job routing issues and
ensures proper task distribution across workers.
2025-09-18 18:27:10 +02:00
lebaudantoine 46fdbc0430 (helm) configure MinIO webhook with Kubernetes job for recordings
Implement automated MinIO webhook configuration using Kubernetes job
to enable recording feature functionality. This eliminates manual
setup requirements and ensures consistent webhook configuration
across deployments.
2025-09-18 18:27:10 +02:00
lebaudantoine 534f3b2d47 🐛(helm) fix MinIO webhook certificate after Tilt stack changes
Restore certificate mounting for MinIO webhook communication to
backend after migrating away from unmaintained Bitnami chart.
Mount certificate in proper volume to enable secure bucket-to-backend
webhook delivery.
2025-09-18 18:27:10 +02:00
244 changed files with 11537 additions and 2496 deletions
+25 -7
View File
@@ -14,6 +14,8 @@ on:
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
jobs:
build-and-push-backend:
@@ -27,7 +29,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-backend
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -40,7 +42,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
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,7 +65,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -76,7 +78,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
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
@@ -100,7 +102,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend-dinum
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -113,7 +115,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
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
@@ -137,7 +139,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-summary
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -145,6 +147,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -176,6 +186,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+30
View File
@@ -34,6 +34,32 @@ jobs:
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
@@ -183,6 +209,10 @@ jobs:
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com
steps:
- name: Checkout repository
+71 -2
View File
@@ -1,4 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -9,4 +8,74 @@ and this project adheres to
## [Unreleased]
- 🔧(backend) support `_FILE` for secret environment variables #566
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
### Added
- ✨(agent) support Kyutai client for subtitle
- ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
## [1.0.1] - 2025-12-17
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
-65
View File
@@ -45,7 +45,6 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -54,21 +53,6 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -153,55 +137,6 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
+26 -9
View File
@@ -34,9 +34,10 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording
- Meeting transcription (currently in beta)
- Meeting transcription & Summary (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
@@ -54,6 +55,7 @@ Were continuously adding new features to enhance your experience, with the la
- [Get started](#get-started)
- [Docs](#docs)
- [Self-host](#self-host)
- [Contributing](#contributing)
- [Philosophy](#philosophy)
- [Open source](#open-source)
@@ -61,22 +63,37 @@ Were continuously adding new features to enhance your experience, with the la
## Get started
### La Suite Meet Cloud (Recommended)
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
### Open-source deployment (Advanced)
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
## Self-host
### La Suite Meet is easy to install on your own servers
We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
**Questions?** Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
> [!NOTE]
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access |
|---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
We <3 contributions of any kind, big and small:
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
+27 -1
View File
@@ -46,6 +46,21 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/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 &&
exit 0;"
app-dev:
build:
context: .
@@ -72,8 +87,12 @@ services:
- nginx
- livekit
- createbuckets
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
- resource-server
- default
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -129,6 +148,9 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
networks:
- resource-server
- default
frontend:
user: "${DOCKER_USER:-1000}"
@@ -213,7 +235,7 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress
image: livekit/egress:v1.11.0
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
@@ -282,3 +304,7 @@ services:
watch:
- action: rebuild
path: ./src/summary
networks:
default:
resource-server:
+7 -2
View File
@@ -24,7 +24,7 @@ RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
npx @posthog/cli@0.4.8 sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
@@ -42,7 +42,12 @@ COPY ./docker/dinum-frontend/fonts/ \
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
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
USER nginx
@@ -0,0 +1,50 @@
upstream meet_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_HOST}:8080 fail_timeout=0;
}
server {
listen 8083;
server_name localhost;
charset utf-8;
# Disables server version feedback on pages and in headers
server_tokens off;
proxy_ssl_server_name on;
location @proxy_to_meet_backend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_backend;
}
location @proxy_to_meet_frontend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_frontend;
}
location / {
try_files $uri @proxy_to_meet_frontend;
}
location /api {
try_files $uri @proxy_to_meet_backend;
}
location /admin {
try_files $uri @proxy_to_meet_backend;
}
location /static {
try_files $uri @proxy_to_meet_backend;
}
}
+23
View File
@@ -0,0 +1,23 @@
version: '3'
# You can add any necessary service here that will join the same docker network
# sharing keycloak. Services added to the 'meet_resource-server' network will be
# able to communicate with keycloak and the backend on that network.
services:
# busybox service is only used for testing purposes. It provides curl to test
# connectivity to the backend and keycloak services. Replace this with your
# relevant application services that need to communicate with keycloak.
busybox:
image: alpine:latest
privileged: true
command: sh -c "apk add --no-cache curl && sleep infinity"
stdin_open: true
tty: true
networks:
- default
- meet_resource-server
networks:
default: {}
meet_resource-server:
external: true
-81
View File
@@ -1,81 +0,0 @@
# LiveKit Egress
LiveKit offers Universal Egress, designed to provide universal exports of LiveKit sessions or tracks to a file or stream data.
It is kept in a separate system to keep the load off the [Single Forwarding Unit (SFU)](https://docs.livekit.io/reference/internals/livekit-sfu/) and avoid impacting real-time audio or video performance/quality.
## Getting started
### Prerequisite
1. **Verify Services**: Ensure the LiveKit server and Egress service are both up and running.
2. **Install CLI**: Confirm that the LiveKit CLI utility is installed on your system.
3. **Set Permissions**: Since the Egress service does not run as the root user, you need to grant write permissions to all users for the output directory. Update the permissions of the `docker/livekit/out` folder before starting the docker-compose stack:
```bash
$ chmod o+w ./docker/livekit/out
```
### Make a recording
LiveKit provides examples for creating Egress requests, which you can find [here](https://github.com/livekit/livekit-cli/tree/main/cmd/livekit-cli/examples). One of these examples has been added to the repository under `docker/livekit/egress-example`.
Follow these steps to start an Egress request:
1. **Create a Room**: Create a room either through the frontend or using the `livekit-cli` command.
2. **Retrieve Room Name**: Get the room's name (e.g., the UUID4 in the URL from the frontend).
3. **Update Configuration**: Edit the `docker/livekit/egress-example/room-composite-file.json` file with your room's name.
4. **Start Egress Request**: Initiate a new Egress request.
```bash
$ livekit-cli start-room-composite-egress --request ./docker/livekit/egress-example/room-composite-file.json
Using default project meet
EgressID: EG_XXXXXXXXXXXX Status: EGRESS_STARTING
```
You can list running Egress:
```Bash
$ livekit-cli list-egress
Using default project meet
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
```
You can stop the Egress at any time once your recording is finished:
```Bash
$ livekit-cli stop-egress --id EG_XXXXXXXXXXXX
Using default project meet
Stopping Egress EG_XXXXXXXXXXXX
```
The Egress should be marked as completed:
```bash
$ livekit-cli list-egress
Using default project meet
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_COMPLETE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
```
Finally, you should find two new files in the `./docker/livekit/out directory`: an `.mp4` recording and its associated metadata in a `.json` file:
```bash
$ ls ./docker/livekit/out
your-room-name-YYYY-MM-DDTHHMMSS.mp4
your-room-name-YYYY-MM-DDTHHMMSS.mp4.json
```
### Resources
[Official Egress repository](https://github.com/livekit/egress)
+88
View File
@@ -0,0 +1,88 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend:
image: lasuite/meet-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
livekit:
condition: service_started
frontend:
image: lasuite/meet-frontend:latest
user: "${DOCKER_USER:-1000}"
entrypoint:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
depends_on:
backend:
condition: service_healthy
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
livekit:
image: livekit/livekit-server:latest
command: --config /config.yaml
ports:
- 7881:7881/tcp
- 7882:7882/udp
volumes:
- ./livekit-server.yaml:/config.yaml
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
# - VIRTUAL_PORT=7880 # used by nginx proxy
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
depends_on:
redis:
condition: service_started
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
+92
View File
@@ -0,0 +1,92 @@
# Deploy and Configure Keycloak for Meet
## Installation
> [!CAUTION]
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
### Step 1: Prepare your working environment:
```bash
mkdir keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
```
### Step 2:. Update `env.d/` files
The following variables need to be updated with your own values, others can be left as is:
```env
POSTGRES_PASSWORD=<generate postgres password>
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
```
### Step 3: Expose keycloak instance on https
> [!NOTE]
> You can skip this section if you already have your own setup.
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
keycloak:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
### Step 4: Start the service
```bash
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.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
### Step 1: Create a New Realm
1. Log in to the Keycloak administration console.
2. Navigate to the realm tab and click on the "Create realm" button.
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
3. Enter the client ID - e.g. `meet`.
4. Enable "Client authentication" option.
6. Set the "Valid redirect URIs" to the URL of your meet application suffixed with `/*` - e.g., "https://meet.example.com/*".
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
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
@@ -0,0 +1,36 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:latest
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
postgresql:
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#
#networks:
# proxy-tier:
# external: true
@@ -0,0 +1,39 @@
# Nginx proxy with automatic SSL certificates
> [!CAUTION]
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
## Installation
### Step 1: Prepare your working environment:
```bash
mkdir nginx-proxy && cd nginx-proxy
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
```
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
### Step 3: Create docker network
Containers need share the same network for auto-discovery.
```bash
docker network create proxy-tier
```
### Step 4: Start service
```bash
docker compose up -d
```
## Usage
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
@@ -0,0 +1,36 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
networks:
proxy-tier:
external: true
volumes:
html:
certs:
acme:
+22
View File
@@ -0,0 +1,22 @@
port: 7880
redis:
address: redis:6379
keys:
meet: <your livekit secret key>
# WebRTC configuration
rtc:
# # when set, LiveKit will attempt to use a UDP mux so all UDP traffic goes through
# # listed port(s). To maximize system performance, we recommend using a range of ports
# # greater or equal to the number of vCPUs on the machine.
# # port_range_start & end must not be set for this config to take effect
udp_port: 7882
# when set, LiveKit enable WebRTC ICE over TCP when UDP isn't available
# this port *cannot* be behind load balancer or TLS, and must be exposed on the node
# WebRTC transports are encrypted and do not require additional encryption
# only 80/443 on public IP are allowed if less than 1024
tcp_port: 7881
# use_external_ip should be set to true for most cloud environments where
# the host has a public IP address, but is not exposed to the process.
# LiveKit will attempt to use STUN to discover the true IP, and advertise
# that IP with its clients
use_external_ip: true
+53
View File
@@ -0,0 +1,53 @@
# Authentication (OIDC)
La Suite Meet supports **OIDC authentication** using the Authorization Code Flow.
Authentication relies on [django-lasuite](https://github.com/suitenumerique/django-lasuite) for OIDC integration, token validation, and user management.
## OIDC Configuration
| Option | Description | Default |
|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------ |
| **Client Settings** | | |
| OIDC_RP_CLIENT_ID | OIDC client identifier registered with your provider | `meet` |
| OIDC_RP_CLIENT_SECRET | OIDC client secret (keep confidential) | — |
| OIDC_CREATE_USER | Automatically create a local user if none exists | `true` |
| **Security & Verification** | | |
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
| **Endpoints** | | |
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
| OIDC_OP_TOKEN_ENDPOINT | URL to exchange authorization code for tokens | — |
| OIDC_OP_USER_ENDPOINT | URL to fetch user information | — |
| OIDC_OP_USER_ENDPOINT_FORMAT | Format of user endpoint response. Options: `AUTO` (detect automatically), `JWT`, or `JSON` | `AUTO` |
| OIDC_OP_LOGOUT_ENDPOINT | URL for logout requests | — |
| **User Info Mapping** | | |
| OIDC_USERINFO_FULLNAME_FIELDS | List of OIDC claims used to build users full name | `["given_name", "usual_name"]` |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC claim used for the users short name | `given_name` |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | List of essential claims required from the provider | `[]` |
| **Redirects & Scopes** | | |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC redirect URIs (**recommended in production**) | `false` |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirects | `[]` |
| OIDC_REDIRECT_FIELD_NAME | Query parameter name used for redirect after login | `returnTo` |
| OIDC_RP_SCOPES | Scopes to request during authentication | `openid email` |
| LOGIN_REDIRECT_URL | URL to redirect after successful login | — |
| LOGIN_REDIRECT_URL_FAILURE | URL to redirect after failed login | — |
| LOGOUT_REDIRECT_URL | URL to redirect after logout | — |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through HTTP GET (POST is recommended for security) | `true` |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters to include in OIDC authentication requests | `{}` |
| **PKCE (Proof Key for Code Exchange)** | | |
| OIDC_USE_PKCE | Enable PKCE for enhanced security (**recommended**) | `false` |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method to generate PKCE code challenge (`S256` recommended) | `S256` |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as PKCE code verifier (43128 characters) | `64` |
| **Other** | | |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Silent login allows La Suite Meet to authenticate users automatically without showing a login prompt, providing a seamless experience when an active session already exists with the OIDC provider. It works by replaying the authentication request with prompt=none: if the user has a valid session, login succeeds silently; otherwise, it fails gracefully and redirects the user to the initial page. Silent login is optional and enabled by default in standard deployments. The app retries silent login after any 401 response, with at least a 30-second interval between attempts (not configurable via environment variables). Controlled by the backend parameter. /!\ Your OIDC provider must support `prompt=none`. | `false` |
## Sessions
* After login, users receive a **Django session cookie** to maintain authentication across requests.
* Default session duration is 12 hours (`SESSION_COOKIE_AGE = 60 * 60 * 12`).
* Ensure your session policy matches your security requirements.
+6
View File
@@ -0,0 +1,6 @@
# Calendar integrations (WIP)
These features are currently under active development and are not yet ready for official documentation. Comprehensive documentation will be provided as soon as possible.
An initial integration with OpenExchange is already available and will be documented shortly.
+143
View File
@@ -0,0 +1,143 @@
# Room Recording (Beta)
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
**Current Limitations**:
* Users cannot record and transcribe simultaneously. ([Issue #527](https://github.com/suitenumerique/meet/issues/527)
is on our backlog)
* Recording layout cannot be configured from the frontend. By default, the egress captures the active speaker and any shared screens. (not yet planned)
* Shareable links with an embedded video player are not yet supported. (not yet planned)
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Special requirements
To use the room recording feature, the following components are required:
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
- An email service to notify room owners when a recording is available for download.
- Webhook events configured between LiveKit Server and the backend.
> [!CAUTION]
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
> [!NOTE]
> Celery isnt in use for these async tasks yet. Its something wed like to add, but its not planned at this stage.
## How It Works
```mermaid
sequenceDiagram
participant User
participant Frontend as Frontend (React)
participant Backend as Django Backend
participant LiveKit as LiveKit API
participant Egress as LiveKit Egress
participant Storage as Object Storage
participant Room as LiveKit Room
participant Email as Email Service
User->>Frontend: Click start recording button
Frontend->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
Backend->>LiveKit: Create egress request
LiveKit->>Egress: Start room composite egress
Egress->>Room: Join room as recording participant
Note over Egress,Room: Egress joins room to capture audio/video
LiveKit-->>Backend: Return egress_id
Backend->>Backend: Update Recording with worker_id
Backend-->>Frontend: HTTP 201 - Recording started
Frontend->>Frontend: Update recording status
Frontend->>Frontend: Notify other participants
Note over Frontend: Via LiveKit data channel
Note over Egress,Room: Recording in progress...
User->>Frontend: Click stop recording button
Frontend->>Backend: POST /api/v1.0/rooms/{id}/stop-recording/
Backend->>LiveKit: Stop egress request
LiveKit->>Egress: Stop recording
Egress->>Storage: Upload recorded file
Storage->>Backend: Storage event notification
Backend->>Backend: Update Recording status to SAVED
Backend->>Email: Send notification to room owner
Backend-->>Frontend: HTTP 200 - Recording stopped
Frontend->>Frontend: Update UI and notify participants
Email->>User: Send email with recording link
User->>Frontend: Navigate to /recording/{id} to download file
Frontend->>Frontend: Download recording file
```
## Configuration Options
| Option | Type | Default | Description |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
| **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. |
### Manual Storage Webhook
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
1. Configure your S3 bucket to send file creation events to the backend webhook.
2. Enable events and token in settings:
```python
RECORDING_STORAGE_EVENT_ENABLE = True
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
RECORDING_STORAGE_EVENT_TOKEN = <token>
```
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## LiveKit Egress
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
Currently, only `RoomCompositeEgress` is supported. This mode combines all video and audio tracks from the room into a single recording.
To monitor egress workers and inspect recording status, it is recommended to install `livekit-cli`. For example, you can list active egress sessions using the following command:
```bash
$ livekit-cli list-egress
Using default project meet
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
```
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
+24
View File
@@ -0,0 +1,24 @@
# Signaling
Signaling is essential for LiveKits real-time communication. It enables peers to discover each other, exchange session descriptions, and negotiate network paths for audio and video streams.
## How Signaling Works
LiveKit signaling relies on a WebSocket connection between the client and the LiveKit API server. This WebSocket is required for all signaling messages, including session descriptions, ICE candidates, and connection state updates.
We do not cover internal signaling behavior. For full reference, see the [LiveKit client protocol](https://docs.livekit.io/reference/internals/client-protocol/).
> [!IMPORTANT]
> The WebSocket is the backbone of LiveKit signaling. All signaling messages rely on it, and without it, ICE candidate exchange and peer connection setup cannot occur. If the WebSocket connection is lost, the client automatically attempts to resume the RTC session once connectivity is restored.
## Environment Variables
| Variable | Type | Default | Purpose |
| ----------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LIVEKIT_FORCE_WSS_PROTOCOL` | Boolean | `True` | Forces the WebSocket URL to use `wss://`. Required for legacy browsers (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in `WebSocket()` may fail. |
| `LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND` | Boolean | `True` | Workaround for Firefox clients behind proxies that fail to establish WebSocket connections. Pre-establishes a dummy connection to “prime” the WebSocket. |
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
+4
View File
@@ -0,0 +1,4 @@
# Live subtitles (WIP)
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
+4
View File
@@ -0,0 +1,4 @@
# Meeting summarization (WIP)
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
+87
View File
@@ -0,0 +1,87 @@
# Telephony SIP (Beta)
Enable participants to join a video conference via phone, allowing them to participate in the room even when their internet connection is poor or unavailable.
**Current Limitations**:
* Supports only a single SIP trunk provider per instance.
* A participant joining over the phone cannot enter the room until the first WebRTC participant has connected.
## Special requirements
To use the telephony feature, the following components are required:
* A running [LiveKit SIP server](https://github.com/livekit/sip) ([documentation](https://docs.livekit.io/home/self-hosting/sip-server/)) to handle SIP participants and connect them to room sessions.
* A SIP trunk to route incoming and outgoing phone calls.
* Webhook events configured between the LiveKit server and the backend.
## How It Works
### Room Lifecycle
```mermaid
sequenceDiagram
participant Backend
participant LiveKit as LiveKit Service
participant SIP as LiveKit SIP
participant Dispatch as SIP Dispatch
Backend->>Backend: Create new room
Backend->>Backend: Assign unique pin code to room
LiveKit-->>Backend: Webhook room_started
Backend->>Dispatch: Create LiveKit SIP dispatch rule
LiveKit-->>Backend: Webhook room_ended
Backend->>Dispatch: Clear LiveKit SIP dispatch rule
```
### Participant calling
```mermaid
sequenceDiagram
participant Caller as Caller
participant SIPProvider as SIP Trunk Provider
participant LiveKitSIP as LiveKit SIP
participant Dispatch as SIP Dispatch
participant Room as LiveKit Room
Caller->>SIPProvider: Dial phone number
SIPProvider->>LiveKitSIP: Route call to SIP server
LiveKitSIP->>Caller: Prompt for room pin code
Caller->>LiveKitSIP: Enter pin code
LiveKitSIP->>Dispatch: Check dispatch rule for pin and trunk ID
Dispatch-->>LiveKitSIP: Return room ID if found
LiveKitSIP->>Room: Connect participant to room
```
## Configuration
| Option | Type | Default | Description |
| ------------------------------ | ---------------- | ------- |-----------------------------------------------------------------------------------------------------------------|
| ROOM_TELEPHONY_ENABLED | Boolean | False | Enable or disable telephony (phone call) support for rooms. |
| ROOM_TELEPHONY_PIN_LENGTH | Positive Integer | 10 | Length of the PIN code participants must enter to join a call. |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Positive Integer | 5 | Maximum number of attempts a participant can make when entering the PIN. |
| ROOM_TELEPHONY_PHONE_NUMBER | String | None | The phone number associated with the room for incoming calls. Required to route calls via the telephony system. |
| ROOM_TELEPHONY_DEFAULT_COUNTRY | String | "US" | Default country code for phone numbers, used for parsing and formatting phone numbers. |
### SIP Trunk Authentication
You may need to configure authentication between LiveKit SIP and your SIP trunk provider to enable participants to join via phone.
Please refer to [the official documentation](https://docs.livekit.io/sip/quickstarts/configuring-sip-trunk/).
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
### Language Customization for Audio Prompts
You may need to configure the default LiveKit voice to match your locale. By default, all LiveKit audio instructions are in English.
To customize the prompts, mount the appropriate audio files as a volume in your deployment. The audio resources are available here: [LiveKit SIP audio files](https://github.com/livekit/sip/tree/main/res).
## Documentation
For detailed information on integrating and configuring SIP with LiveKit, refer to the official LiveKit SIP documentation: [LiveKit SIP Documentation](https://docs.livekit.io/sip/). This guide covers SIP server setup, trunk configuration, dispatch rules, etc.
+92
View File
@@ -0,0 +1,92 @@
# Transcription
La Suite Meet provides a room transcription capability, currently available in beta. This feature is under active development, with ongoing enhancements planned.
The transcription feature enables users to record room sessions. Upon completion of a recording, the room owner receives a notification containing a link to LaSuite Docs, where the transcribed meeting content can be accessed.
> [!NOTE]
> Audio recordings are automatically deleted after the configured `RECORDING_EXPIRATION_DAYS` period.
For configuration and setup details of the recording functionality, refer to the [Recording feature documentation](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
This page only describes the supplementary tools required for audio processing.
Example of a transcript :
```
**SPEAKER_00**: Hello everyone!
**SPEAKER_01**: Yes, it works.
```
### Current Limitations
* Participant identification is not yet implemented; participants are labeled generically (e.g., `PARTICIPANT_1`).
* Transcription backend relies on [WhisperX](https://github.com/m-bain/whisperX), which does not provide an OpenAI-compatible API.
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Special requirements
To enable the transcription feature, the following components must be in place:
* Recording feature components: All dependencies and configurations required for the [recording feature](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
* LaSuite Docs instance: A running [LaSuite Docs](https://github.com/suitenumerique/docs) capable of handling requests to the `/create-for-owner` endpoint.
* WhisperX API: A running WhisperX service. An open-source implementation combining WhisperX and FastAPI is available [here](https://github.com/suitenumerique/meet-whisperx).
* Deployment of the [summary service](https://hub.docker.com/r/lasuite/meet-summary), a Celery worker, and a Redis instance.
## How It Works
```mermaid
sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant STT as WhisperX API
participant Docs as LaSuite Docs
Backend->>Summary: POST /api/v1/tasks/ (bearer token, payload)
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
Summary->>Celery: Register task (transcribe-queue)
Celery->>MinIO: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
Celery->>Celery: Format transcript (text)
Celery->>Docs: POST /create-for-owner (title, content, email, sub, api token)
Docs-->>Celery: Acknowledgement
```
## Configuration Options
| Option | Type | Default | Description |
| ------------------------ | --------- |-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| app_name | String | `"app"` | Name of the application/service. |
| app_api_v1_str | String | `"/api/v1"` | Base path for the API endpoints. |
| app_api_token | Secret | — | API token for authenticating requests. |
| recording_max_duration | Integer | `None` | Maximum duration of audio recordings in milliseconds. Set to `None` for unlimited. Audio recordings longer than the configured limit will be ignored and not processed. |
| celery_broker_url | String | `"redis://redis/0"` | Celery broker URL. |
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
| webhook_api_token | Secret | — | Token to authenticate incoming webhook requests. |
| webhook_url | String | — | URL to which webhook events are sent. |
| document_default_title | String | `"Transcription"` | Default title for generated documents. |
| document_title_template | String | `'Réunion "{room}" du {room_recording_date} à {room_recording_time}'` | Template for document title. |
| sentry_is_enabled | Boolean | `False` | Enable or disable Sentry error tracking. |
| sentry_dsn | String | `None` | DSN for Sentry integration. |
+25
View File
@@ -0,0 +1,25 @@
# Installation
If you want to install La Suite Meet you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
## Docker Compose
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
Here is the list of other methods in alphabetical order:
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&show=lasuite-meet&query=lasuite-meet), ⚠️ unstable
- Yunohost: [Packages](https://github.com/YunoHost-Apps/meet_ynh), ⚠️ under construction (for small instances only)
> [!TIP]
> Feel free to make a PR to add ones that are not listed above
## Cloud providers
Currently, no cloud providers are listed for deploying La Suite Meet.
> [!TIP]
> Feel free to make a PR to add ones that are not listed above
+227
View File
@@ -0,0 +1,227 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
## Requirements
All services are required to run the minimalist instance of LaSuite Meet. Click the links for ready-to-use configuration examples:
| Service | Purpose | Example Config |
|-------------------|---------|----------------------------------------------------------|
| **PostgreSQL** | Main database | [compose.yaml](../examples/compose/compose.yaml) |
| **Redis** | Cache & sessions | [compose.yaml](../examples/compose/compose.yaml) |
| **Livekit** | Real-time communication | [compose.yaml](../examples/compose/compose.yaml) |
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
| **SMTP Service** | Email notifications | - |
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
## Software Requirements
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
Docker Compose is included with Docker Engine:
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
for [installing Docker Engine](https://docs.docker.com/engine/install/).
For older versions of Docker Engine that do not include Docker Compose:
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
> [!NOTE]
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
## Step 1: Prepare your working environment:
```bash
mkdir -p meet/env.d && cd meet
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/compose.yaml
curl -o .env https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/hosts
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/common
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/postgresql
curl -o livekit-server.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/livekit/server.yaml
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docker/files/production/default.conf.template
```
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
- OIDC provider on https://id.yourdomain.tld
- Livekit server on https://livekit.yourdomain.tld
- Meet server on https://meet.yourdomain.tld
**Set your own values in `.env`**
### OIDC
Authentication in Meet is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
If using Keycloak as your Identity Provider, in `env.d/common` set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Meet. By default we have set `meet` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `.env`
For others OIDC providers, update the variables in `env.d/common`.
### Postgresql
Meet uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
### Redis
Meet uses Redis for caching and inter-service communication. While an external Redis can be used, our example provides a deployment method.
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/common`.
### Livekit
[LiveKit](https://github.com/livekit/livekit) server is used as the WebRTC SFU (Selective Forwarding Unit) allowing multi-user conferencing. For more information, head to [livekit documentation](https://docs.livekit.io/home/self-hosting/).
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
### Meet
The Meet backend is built on the Django Framework.
Generate a [secure key](https://docs.djangoproject.com/en/5.2/ref/settings/#secret-key.) for `DJANGO_SECRET_KEY` in `env.d/common`.
### Mail
The following environment variables are required in `env.d/common` for the mail service to send invitations :
```env
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
The following ports will need to be opened:
- 80/tcp - for TLS issuance
- 443/tcp - for listening on HTTPS and TURN/TLS packets
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the follwoing:
```
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 443/udp
ufw allow 7881/tcp
ufw allow 7882/udp
ufw enable
```
## Step 4: Reverse proxy and SSL/TLS
> [!WARNING]
> In a production environment, configure SSL/TLS termination to run your instance on https.
If you have your own certificates and proxy setup, you can skip this part.
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
You will need to uncomment the environment and network sections in compose file and update it with your values.
```yaml
frontend:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
...
# environment:
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
# - VIRTUAL_PORT=7880 # used by nginx proxy
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#networks:
# proxy-tier:
# external: true
```
## Step 5: Start Meet
You are ready to start your Meet application !
```bash
docker compose up -d
```
> [!NOTE]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
## Step 6: Run the database migration and create Django admin user
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
```
Replace `<admin email>` with the email of your admin user and generate a secure password.
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
### Step 1: Edit the images tag with the desired version
### Step 2: Pull the images
```bash
docker compose pull
```
### Step 3: Restart your containers
```bash
docker compose restart
```
### Step 4: Run the database migration
Your database schema may need to be updated, run:
```bash
docker compose run --rm backend python manage.py migrate
```
@@ -277,7 +277,6 @@ These are the environmental options available on meet backend.
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
+474
View File
@@ -0,0 +1,474 @@
openapi: 3.0.3
info:
title: Meet External API
version: 1.0.0
description: |
External API for room management with application-delegated authentication.
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token/`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
#### Scopes
* `rooms:list` List rooms accessible to the delegated user.
* `rooms:retrieve` Retrieve details of a specific room.
* `rooms:create` Create new rooms.
* `rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
contact:
name: API Support
email: antoine.lebaud@mail.numerique.gouv.fr
servers:
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
description: Sandbox server
tags:
- name: Authentication
description: Application authentication and token generation
- name: Rooms
description: Room management operations
paths:
/application/token/:
post:
tags:
- Authentication
summary: Generate JWT token
description: |
Exchange application credentials for a scoped JWT token that allows the application
to act on behalf of a specific user.
The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
operationId: generateToken
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
responses:
'200':
description: Token generated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
tokenResponse:
summary: Successful token generation
value:
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
token_type: "Bearer"
expires_in: 3600
scope: "rooms:list rooms:retrieve rooms:create"
'401':
description: Authentication failed
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
invalidCredentials:
summary: Invalid credentials
value:
error: "Invalid credentials"
inactiveApplication:
summary: Application is inactive
value:
error: "Application is inactive"
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
userNotFound:
summary: User not found
value:
error: "User not found."
'403':
description: Access denied - cannot delegate user
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
delegationDenied:
summary: Domain not authorized
value:
error: "This application is not authorized for this email domain."
/rooms:
get:
tags:
- Rooms
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
parameters:
- name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
- name: page_size
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: List of accessible rooms
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of rooms
next:
type: string
nullable: true
description: URL to next page
previous:
type: string
nullable: true
description: URL to previous page
results:
type: array
items:
$ref: '#/components/schemas/Room'
examples:
roomList:
summary: Paginated room list
value:
count: 2
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
previous: null
results:
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
post:
tags:
- Rooms
summary: Create a room
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing
operationId: createRoom
security:
- BearerAuth: [rooms:create]
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/{id}:
get:
tags:
- Rooms
summary: Retrieve a room
description: Get detailed information about a specific room by its ID
operationId: retrieveRoom
security:
- BearerAuth: [rooms:retrieve]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
responses:
'200':
description: Room details
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
examples:
room:
summary: Room details
value:
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token/` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate:
type: object
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
description: Unique room identifier
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug:
type: string
readOnly: true
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
readOnly: true
description: Full URL to access the room
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
telephony:
type: object
readOnly: true
description: Telephony dial-in information (if enabled)
properties:
enabled:
type: boolean
description: Whether telephony is available
example: true
pin_code:
type: string
description: PIN code for dial-in access
example: "123456"
phone_number:
type: string
description: Phone number to dial
example: "+1-555-0100"
default_country:
type: string
description: Default country code
example: "US"
OAuthError:
type: object
description: OAuth2-compliant error response
properties:
error:
type: string
description: Human-readable error description
example: "Invalid credentials"
Error:
type: object
properties:
detail:
type: string
description: Error message
example: "Invalid token."
ValidationError:
type: object
properties:
field_name:
type: array
items:
type: string
description: List of validation errors for this field
example: ["This field is required."]
responses:
UnauthorizedError:
description: Authentication required or token invalid/expired
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidToken:
summary: Invalid or expired token
value:
error: "Invalid token."
tokenExpired:
summary: Token has expired
value:
error: "Token expired."
ForbiddenError:
description: Insufficient permissions for this operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
insufficientScope:
summary: Missing required scope
value:
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
RoomNotFoundError:
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
roomNotFound:
summary: Room does not exist
value:
detail: "Not found."
BadRequestError:
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
examples:
validationError:
summary: Field validation failed
value:
scope: ["Invalid email address."]
-65
View File
@@ -1,65 +0,0 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin --tags
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
+393
View File
@@ -0,0 +1,393 @@
openapi: 3.0.3
info:
title: Meet External API
version: 1.0.0
description: |
External API for room management with resource server authentication.
[[description by Oauth 2.0]](https://www.oauth.com/oauth2-servers/the-resource-server/)
#### Authentication Flow
1. Authenticate with the authorization server using your credentials
2. During authentication, request the scopes you need: `lasuite_visio` (mandatory) plus action-specific scopes
3. Receive an access token and a refresh token that includes the requested scopes
4. Use the access token in the `Authorization: Bearer <token>` header for all API requests
5. When the access token expires, use the refresh token to obtain a new access token without re-authenticating
#### Scopes
* `lasuite_visio` - **Mandatory** Base scope required for any API access
* `lasuite_visio:rooms:list` List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` Retrieve details of a specific room.
* `lasuite_visio:rooms:create` Create new rooms.
* `lasuite_visio:rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
contact:
name: API Support
email: antoine.lebaud@mail.numerique.gouv.fr
servers:
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
description: Sandbox server
tags:
- name: Rooms
description: Room management operations
paths:
/rooms:
get:
tags:
- Rooms
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
parameters:
- name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
- name: page_size
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: List of accessible rooms
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of rooms
next:
type: string
nullable: true
description: URL to next page
previous:
type: string
nullable: true
description: URL to previous page
results:
type: array
items:
$ref: '#/components/schemas/Room'
examples:
roomList:
summary: Paginated room list
value:
count: 2
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
previous: null
results:
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
post:
tags:
- Rooms
summary: Create a room
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing
operationId: createRoom
security:
- BearerAuth: [rooms:create]
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/{id}:
get:
tags:
- Rooms
summary: Retrieve a room
description: Get detailed information about a specific room by its ID
operationId: retrieveRoom
security:
- BearerAuth: [rooms:retrieve]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
responses:
'200':
description: Room details
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
examples:
room:
summary: Room details
value:
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate:
type: object
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
description: Unique room identifier
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug:
type: string
readOnly: true
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
readOnly: true
description: Full URL to access the room
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
telephony:
type: object
readOnly: true
description: Telephony dial-in information (if enabled)
properties:
enabled:
type: boolean
description: Whether telephony is available
example: true
pin_code:
type: string
description: PIN code for dial-in access
example: "123456"
phone_number:
type: string
description: Phone number to dial
example: "+1-555-0100"
default_country:
type: string
description: Default country code
example: "US"
OAuthError:
type: object
description: OAuth2-compliant error response
properties:
error:
type: string
description: Human-readable error description
example: "Invalid credentials"
Error:
type: object
properties:
detail:
type: string
description: Error message
example: "Invalid token."
ValidationError:
type: object
properties:
field_name:
type: array
items:
type: string
description: List of validation errors for this field
example: ["This field is required."]
responses:
UnauthorizedError:
description: The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidToken:
summary: Invalid token
value:
error: "Invalid token."
ForbiddenError:
description: Insufficient scope for this operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
insufficientScope:
summary: Missing required scope
value:
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
RoomNotFoundError:
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
roomNotFound:
summary: Room does not exist
value:
detail: "Not found."
BadRequestError:
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
examples:
validationError:
summary: Field validation failed
value:
scope: ["Invalid email address."]
+118
View File
@@ -0,0 +1,118 @@
# Theming La Suite Meet
There are two ways to customize LaSuite Meet:
- **Runtime Theming**. You can load a custom CSS file to apply any CSS you want. You can change all design-system tokens through CSS variables: colors, fonts, spacing multipliers, and more.
- **Build-time Theming**. Some additional things, like the app name appearing in the browser tab, can be customized through environment variables that are applied at build-time.
## Runtime Theming
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=https://example.com/custom-style.css
```
> [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
This feature lets you customize the apps look with any CSS, giving full flexibility and allowing changes to take effect instantly at runtime without touching the code.
### Example Use Case
Let's say you want to change the font of our application to a custom font. You can create a custom CSS file with the following contents:
```css
@import url(https://fonts.bunny.net/css?family=Roboto:wght@400;700&display=swap);
:root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
> The app does **not provide separate light/dark themes**: outside a meeting it defaults to light, and in a room it switches to dark.
### Key Semantic Tokens
These control the main visual aspects of the interface:
| Category | Purpose | Example Token |
| ---------- | ------------------------------- | --------------------------- |
| Primary | Brand color, buttons, links | `--colors-primary` |
| Dark Mode | Primary color in room/dark mode | `--colors-primary-dark-500` |
| Greyscale | Text, backgrounds, borders | `--colors-greyscale-500` |
| Success | Success states | `--colors-success` |
| Error | Errors and destructive actions | `--colors-error` |
| Warning | Warnings and alerts | `--colors-warning` |
| Alert | Notification backgrounds | `--colors-alert` |
| Font Sans | Main UI font | `--fonts-sans` |
| Font Serif | Alternate/reading font | `--fonts-serif` |
| Font Mono | Code or technical font | `--fonts-mono` |
### Assets (Logo, Images)
You can override built-in assets (such as the logo or images) by mounting your own files into the container.
Simply bind-mount your custom assets to the following path inside the container:
```
/usr/share/nginx/html/assets
```
Any files you mount here will **override the defaults at runtime**.
For example, to replace the images used in the landing page carousel, provide your own versions with the same filenames and paths:
```
/usr/share/nginx/html/
└── assets/intro-slider/
├── 1.png
├── 2.png
├── 3.png
└── 4.png
```
## Build-Time Theming
Some settings cannot be applied at runtime and require rebuilding the Docker image.
One key example is the **application title and name**, controlled by the `VITE_APP_TITLE` build argument.
* **Default:** `La Suite Meet`
* **Override:** supply your own value at build time
```bash
docker build \
--build-arg VITE_APP_TITLE="My Custom Meet" \
-t my-org/meet:latest .
```
```dockerfile
# Dockerfile
ARG VITE_APP_TITLE="La Suite Meet"
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
```
For a real-world example, see how DINUM rebuilds the frontend to match their branding:
[DINUM Dockerfile](../docker/dinum-frontend/Dockerfile)
----
# **Footer Configuration**
The footer cannot be customized yet. This is a work in progress, and we welcome contributions — feel free to open a pull request if youd like to help add this feature.
You can enable the official French government footer by setting the environment variable `FRONTEND_USE_FRENCH_GOV_FOOTER` to true. This option is disabled (false) by default.
+12 -1
View File
@@ -32,6 +32,8 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/cert
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
OIDC_OP_URL=http://localhost:8083/realms/meet
OIDC_RP_CLIENT_ID=meet
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -45,6 +47,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=meet
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
@@ -58,10 +63,16 @@ RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Telephony
ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
# External Applications
EXTERNAL_API_ENABLED=True
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
+3 -7
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
@@ -11,18 +11,14 @@ AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
WHISPERX_DEFAULT_LANGUAGE="fr"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
LLM_MODEL="albert-large"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
LANGFUSE_SECRET_KEY="your-secret-key"
LANGFUSE_PUBLIC_KEY="your-public-key"
LANGFUSE_HOST="https://cloud.langfuse.com"
LANFUSE_ENABLED="False"
+50
View File
@@ -0,0 +1,50 @@
# Django
DJANGO_ALLOWED_HOSTS=${MEET_HOST}
DJANGO_SECRET_KEY=<generate a secret key>
DJANGO_SETTINGS_MODULE=meet.settings
DJANGO_CONFIGURATION=Production
# Python
PYTHONPATH=/app
# Meet settings
# Mail
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
# OIDC
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=https://${MEET_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${MEET_HOST}
LOGOUT_REDIRECT_URL=https://${MEET_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${MEET_HOST}"]
# Livekit Token settings
LIVEKIT_API_SECRET=<generate a secret key>
LIVEKIT_API_KEY=meet
LIVEKIT_API_URL=https://${LIVEKIT_HOST}
ALLOW_UNREGISTERED_ROOMS=False
+7
View File
@@ -0,0 +1,7 @@
MEET_HOST=meet.domain.tld
KEYCLOAK_HOST=id.domain.tld
LIVEKIT_HOST=livekit.domain.tld
BACKEND_INTERNAL_HOST=backend
FRONTEND_INTERNAL_HOST=frontend
LIVEKIT_INTERNAL_HOST=livekit
REALM_NAME=meet
+13
View File
@@ -0,0 +1,13 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<generate postgres password>
PGDATA=/var/lib/postgresql/data/pgdata
# Keycloak postgresql configuration
KC_DB=postgres
KC_DB_SCHEMA=public
KC_DB_URL_HOST=postgresql
KC_DB_NAME=${POSTGRES_DB}
KC_DB_USER=${POSTGRES_USER}
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
+8
View File
@@ -0,0 +1,8 @@
# Keycloak admin user
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
# Keycloak configuration
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
+11
View File
@@ -0,0 +1,11 @@
# App database configuration
DB_HOST=postgresql
DB_NAME=meet
DB_USER=meet
DB_PASSWORD=<generate a secure password>
DB_PORT=5432
# Postgresql db container configuration
POSTGRES_DB=meet
POSTGRES_USER=meet
POSTGRES_PASSWORD=${DB_PASSWORD}
+6
View File
@@ -9,6 +9,12 @@
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+6
View File
@@ -1,5 +1,11 @@
FROM python:3.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 \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
+33 -13
View File
@@ -5,6 +5,7 @@ import logging
import os
from dotenv import load_dotenv
from lasuite.plugins import kyutai
from livekit import api, rtc
from livekit.agents import (
Agent,
@@ -13,14 +14,15 @@ from livekit.agents import (
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import deepgram, silero
load_dotenv()
@@ -28,6 +30,26 @@ load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider():
"""Create STT provider based on environment configuration."""
if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
_stt_instance = deepgram.STT(
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
)
elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
return _stt_instance
class Transcriber(Agent):
@@ -35,9 +57,11 @@ class Transcriber(Agent):
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
stt = create_stt_provider()
super().__init__(
instructions="not-needed",
stt=deepgram.STT(),
stt=stt,
)
self.participant_identity = participant_identity
@@ -99,19 +123,14 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
options=lk_room_io.RoomOptions(
text_input=False, audio_output=False, text_output=True
),
)
await room_io.start()
@@ -174,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+7 -6
View File
@@ -1,18 +1,19 @@
[project]
name = "agents"
version = "0.1.38"
version = "1.3.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1"
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.12.0",
"ruff==0.14.4",
]
[build-system]
+1
View File
@@ -0,0 +1 @@
"""Meet core add-ons module."""
+126
View File
@@ -0,0 +1,126 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
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 SuspiciousOperation
from core.models import User
from core.services.jwt_token_service import TokenService
logger = getLogger(__name__)
class SessionState(str, Enum):
"""Add-on authentication session states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Initialize the service with the configured token service."""
self._token_service = TokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _get_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def init_session(self) -> str:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
cache_key = self._get_cache_key(session_id)
cache.set(
cache_key,
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
return session_id
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
cache_key = self._get_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
self.clear_session(session_id)
# Return copy without internal fields
internal_fields = {"expires_at"}
return {k: v for k, v in data.items() if k not in internal_fields}
def clear_session(self, session_id: str) -> None:
"""Remove session data from cache."""
cache_key = self._get_cache_key(session_id)
cache.delete(cache_key)
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._get_cache_key(session_id)
existing_data = cache.get(cache_key)
if not existing_data:
raise SuspiciousOperation("Session not found.")
expires_at = existing_data.get("expires_at", None)
if not expires_at:
self.clear_session(session_id)
raise SuspiciousOperation("Invalid session data.")
remaining_seconds = int(
(
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
).total_seconds()
)
if remaining_seconds <= 0:
self.clear_session(session_id)
raise SuspiciousOperation("Session expired.")
if existing_data.get("state") != SessionState.PENDING:
self.clear_session(session_id)
raise SuspiciousOperation("Access token already set.")
response = self._token_service.generate_access_token(
user, settings.ADDONS_SCOPES
)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
+57
View File
@@ -0,0 +1,57 @@
"""Add-ons views."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from core.addons.service import SessionState, TokenExchangeService
def render_error(request, message, status=400):
"""Render simple error page."""
return render(request, "addons/error.html", {"message": message}, status=status)
@require_http_methods(["GET"])
def transit_page(request):
"""Initialize authentication flow for add-on session."""
session_id = request.GET.get("session_id")
if not session_id:
return render_error(request, _("Session ID is required."), status=400)
data = TokenExchangeService().get_session(session_id)
if not data:
return render_error(request, _("Session not found or expired."), status=404)
if data.get("state") != SessionState.PENDING:
return render_error(request, _("Invalid session state."), status=400)
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
return_to = request.build_absolute_uri("/addons/redirect")
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
@require_http_methods(["GET"])
def redirect_page(request):
"""Complete authentication and close the popup window."""
if not request.user.is_authenticated:
return render_error(request, _("Authentication required."), status=401)
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
if not session_id:
return render_error(request, _("No active session found."), status=404)
try:
TokenExchangeService().set_access_token(request.user, session_id)
except SuspiciousOperation:
return render_error(request, _("Invalid or expired session."), status=400)
return render(request, "addons/redirect_success.html")
+53
View File
@@ -0,0 +1,53 @@
"""Add-ons API endpoints"""
from logging import getLogger
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from rest_framework import throttling, viewsets
from core.addons.service import TokenExchangeService
logger = getLogger(__name__)
class AuthSessionThrottle(throttling.AnonRateThrottle):
"""Throttle request to the addons auth session endpoints."""
scope = "addons_auth_sessions"
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = [AuthSessionThrottle]
def create(self, request):
"""Create a new pending authentication session."""
session_id = TokenExchangeService().init_session()
return drf_response.Response(
{"session_id": session_id}, status=drf_status.HTTP_201_CREATED
)
def retrieve(self, request, pk=None):
"""Retrieve authentication session data by session ID."""
data = TokenExchangeService().get_session(pk)
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Delete an authentication session by session ID."""
TokenExchangeService().clear_session(pk)
return drf_response.Response(
{"status": "ok"}, status=drf_status.HTTP_204_NO_CONTENT
)
+147 -3
View File
@@ -1,9 +1,12 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django import forms
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from core.recording.event import notification
from . import models
@@ -108,10 +111,27 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "created_at"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
owners = [
access
for access in obj.accesses.all()
if access.role == models.RoleChoices.OWNER
]
if not owners:
return _("No owner")
if len(owners) > 1:
return _("Multiple owners")
return str(owners[0].user)
class RecordingAccessInline(admin.TabularInline):
"""Inline admin class for recording accesses."""
@@ -120,15 +140,76 @@ class RecordingAccessInline(admin.TabularInline):
extra = 0
@admin.action(description=_("Resend notification to external service"))
def resend_notification(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Resend notification to external service for selected recordings."""
notification_service = notification.NotificationService()
processed = 0
skipped = 0
failed = 0
for recording in queryset:
if recording.is_expired:
skipped += 1
continue
try:
success = notification_service.notify_external_services(recording)
if success:
processed += 1
else:
failed += 1
modeladmin.message_user(
request,
_("Failed to notify for recording %(id)s") % {"id": recording.id},
level=messages.ERROR,
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
failed += 1
modeladmin.message_user(
request,
_("Failed to notify for recording %(id)s: %(error)s")
% {"id": recording.id, "error": str(e)},
level=messages.ERROR,
)
if processed > 0:
modeladmin.message_user(
request,
_("Successfully sent notifications for %(count)s recording(s).")
% {"count": processed},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s expired recording(s).") % {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
list_display = (
"id",
"status",
"mode",
"room",
"get_owner",
"created_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
actions = [resend_notification]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -150,3 +231,66 @@ class RecordingAdmin(admin.ModelAdmin):
return _("Multiple owners")
return str(owners[0].user)
class ApplicationDomainInline(admin.TabularInline):
"""Inline admin for managing allowed domains per application."""
model = models.ApplicationDomain
extra = 0
class ApplicationAdminForm(forms.ModelForm):
"""Custom form for Application admin with multi-select scopes."""
scopes = forms.MultipleChoiceField(
choices=models.ApplicationScope.choices,
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and self.instance.scopes:
self.fields["scopes"].initial = self.instance.scopes
@admin.register(models.Application)
class ApplicationAdmin(admin.ModelAdmin):
"""Admin interface for managing applications and their permissions."""
form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display")
fields = [
"name",
"id",
"created_at",
"updated_at",
"scopes",
"client_id",
"client_secret",
]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline]
def get_readonly_fields(self, request, obj=None):
"""Make client_id and client_secret readonly after creation."""
if obj: # Editing existing object
return self.readonly_fields + ["client_id", "client_secret"]
return self.readonly_fields
def get_fields(self, request, obj=None):
"""Hide client_secret after creation."""
fields = super().get_fields(request, obj)
if obj:
return [f for f in fields if f != "client_secret"]
return fields
def get_scopes_display(self, obj):
"""Display scopes in list view."""
if obj.scopes:
return ", ".join(obj.scopes)
return _("No scopes")
get_scopes_display.short_description = _("Scopes")
+8
View File
@@ -159,6 +159,8 @@ class RoomSerializer(serializers.ModelSerializer):
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
@@ -179,6 +181,7 @@ class RecordingSerializer(serializers.ModelSerializer):
"updated_at",
"status",
"mode",
"options",
"key",
"is_expired",
"expired_at",
@@ -210,6 +213,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
required=False,
allow_null=True,
default=dict,
)
class RequestEntrySerializer(BaseValidationOnlySerializer):
+4 -1
View File
@@ -308,10 +308,13 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["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)
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
+1 -1
View File
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
user = UserModel.objects.get(sub=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
@@ -0,0 +1 @@
"""Meet core external API endpoints"""
@@ -0,0 +1,245 @@
"""Authentication Backends for external application to the Meet core app."""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
User = get_user_model()
logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
secret_key = None
algorithm = None
issuer = None
audience = None
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
Returns:
Tuple of (user, payload) if authentication successful, None otherwise
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = pyJwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
issuer=self.issuer,
audience=self.audience,
)
return payload
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
If token is invalid, defer to next authentication backend.
Args:
token: JWT token string
Returns:
Tuple of (user, payload)
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
payload = self.decode_jwt(token)
if payload is None:
return None
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
secret_key = settings.APPLICATION_JWT_SECRET_KEY
algorithm = settings.APPLICATION_JWT_ALG
issuer = settings.APPLICATION_JWT_ISSUER
audience = settings.APPLICATION_JWT_AUDIENCE
def validate_payload(self, payload):
"""Validate application-specific claims."""
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
Validates JWT tokens issued by addons for authenticating users.
Tokens must include user_id to identify the authenticated user.
"""
secret_key = settings.ADDONS_JWT_SECRET_KEY
algorithm = settings.ADDONS_JWT_ALG
issuer = settings.ADDONS_JWT_ISSUER
audience = settings.ADDONS_JWT_AUDIENCE
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
def get_or_create_user(self, access_token, id_token, payload):
"""Get or create user from OIDC token claims.
Despite the LaSuiteBackend's method name suggesting "get_or_create",
its implementation only performs a GET operation.
Create new user from the sub claim.
Args:
access_token: The access token string
id_token: The ID token string (unused)
payload: Token payload dict (unused)
Returns:
User instance
Raises:
SuspiciousOperation: If user info validation fails
"""
sub = payload.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
@@ -0,0 +1,84 @@
"""Permission handlers for application-delegated API access."""
import logging
from typing import Dict
from django.conf import settings
from rest_framework import exceptions, permissions
from .. import models
logger = logging.getLogger(__name__)
class BaseScopePermission(permissions.BasePermission):
"""Base class for scope-based permission checking.
Subclasses must define `scope_map` attribute mapping actions to required scopes.
"""
scope_map: Dict[str, str] = {}
def has_permission(self, request, view):
"""Check if the JWT token contains the required scope for this action.
Args:
request: DRF request object with authenticated user
view: ViewSet instance
Returns:
bool: True if permission granted
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create')
action = getattr(view, "action", None)
if not action:
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
required_scope = self.scope_map.get(action)
if not required_scope:
# Action not in scope_map, deny by default
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
token_payload = request.auth
token_scopes = token_payload.get("scope")
if not token_scopes:
raise exceptions.PermissionDenied("Insufficient permissions.")
# Ensure scopes is a list (handle both list and space-separated string)
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
for scope in token_scopes
]
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
return True
class HasRequiredRoomScope(BaseScopePermission):
"""Permission class for Room-related operations."""
scope_map = {
"list": models.ApplicationScope.ROOMS_LIST,
"retrieve": models.ApplicationScope.ROOMS_RETRIEVE,
"create": models.ApplicationScope.ROOMS_CREATE,
"update": models.ApplicationScope.ROOMS_UPDATE,
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
@@ -0,0 +1,73 @@
"""Serializers for the external API of the Meet core app."""
# pylint: disable=abstract-method
from django.conf import settings
from rest_framework import serializers
from core import models, utils
from core.api.serializers import BaseValidationOnlySerializer
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
class ApplicationJwtSerializer(BaseValidationOnlySerializer):
"""Validate OAuth2 JWT token request data."""
client_id = serializers.CharField(write_only=True)
client_secret = serializers.CharField(write_only=True)
grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS])
scope = serializers.CharField(write_only=True)
class RoomSerializer(serializers.ModelSerializer):
"""External API serializer for room data exposed to applications.
Provides limited, safe room information for third-party integrations:
- Secure defaults for room creation (trusted access level)
- Computed fields (url, telephony) for external consumption
- Filtered data appropriate for delegation scenarios
- Tracks creation source for auditing
Intentionally exposes minimal information to external applications,
following the principle of least privilege.
"""
class Meta:
model = models.Room
fields = ["id", "name", "slug", "pin_code", "access_level"]
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
def to_representation(self, instance):
"""Enrich response with application-specific computed fields."""
output = super().to_representation(instance)
request = self.context.get("request")
pin_code = output.pop("pin_code", None)
if not request:
return output
# Add room URL for direct access
if settings.APPLICATION_BASE_URL:
output["url"] = f"{settings.APPLICATION_BASE_URL}/{instance.slug}"
# Add telephony information if enabled
if settings.ROOM_TELEPHONY_ENABLED:
output["telephony"] = {
"enabled": True,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER,
"pin_code": pin_code,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
}
return output
def create(self, validated_data):
"""Create room with secure defaults for application delegation."""
# Set secure defaults
validated_data["name"] = utils.generate_room_slug()
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
return super().create(validated_data)
+220
View File
@@ -0,0 +1,220 @@
"""External API endpoints"""
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
from core import api, models
from core.services.jwt_token_service import TokenService
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.GenericViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
detail=False,
methods=["post"],
url_path="token",
url_name="token",
)
def generate_jwt_access_token(self, request, *args, **kwargs):
"""Generate JWT access token for application delegation.
Validates application credentials and generates a JWT token scoped
to a specific user email, allowing the application to act on behalf
of that user.
Note: The 'scope' parameter accepts an email address to identify the user
being delegated. This design allows applications to obtain user-scoped tokens
for delegation purposes. The scope field is intentionally generic and can be
extended to support other values in the future.
Reference: https://stackoverflow.com/a/27711422
"""
serializer = serializers.ApplicationJwtSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
client_id = serializer.validated_data["client_id"]
client_secret = serializer.validated_data["client_secret"]
try:
application = models.Application.objects.get(client_id=client_id)
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")
email = serializer.validated_data["scope"]
try:
validate_email(email)
except ValidationError:
return drf_response.Response(
{
"error": "Scope should be a valid email address.",
},
status=drf_status.HTTP_400_BAD_REQUEST,
)
if not application.can_delegate_email(email):
logger.warning(
"Application %s denied delegation for %s",
application.client_id,
email,
)
return drf_response.Response(
{
"error": "This application is not authorized for this email domain.",
},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
scope = " ".join(application.scopes or [])
token_service = TokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
data = token_service.generate_access_token(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
)
return drf_response.Response(
data,
status=drf_status.HTTP_200_OK,
)
class RoomViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""Application-delegated API for room management.
Provides JWT-authenticated access to room operations for external applications
acting on behalf of users. All operations are scope-based and filtered to the
authenticated user's accessible rooms.
Supported operations:
- list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope)
"""
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
user = self.request.user
if user.is_authenticated:
queryset = (
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
)
else:
queryset = self.get_queryset().none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
room.id,
self.request.user.id,
getattr(self.request.auth, "client_id", "unknown"),
)
+37 -1
View File
@@ -9,7 +9,7 @@ from django.utils.text import slugify
import factory.fuzzy
from faker import Faker
from core import models
from core import models, utils
fake = Faker()
@@ -117,3 +117,39 @@ class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
recording = factory.SubFactory(RecordingFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
class ApplicationFactory(factory.django.DjangoModelFactory):
"""Create fake applications for testing."""
class Meta:
model = models.Application
name = factory.Faker("company")
active = True
client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = []
class Params:
"""Factory traits for common application configurations."""
with_all_scopes = factory.Trait(
scopes=[
models.ApplicationScope.ROOMS_LIST,
models.ApplicationScope.ROOMS_RETRIEVE,
models.ApplicationScope.ROOMS_CREATE,
models.ApplicationScope.ROOMS_UPDATE,
models.ApplicationScope.ROOMS_DELETE,
]
)
class ApplicationDomainFactory(factory.django.DjangoModelFactory):
"""Create fake application domains for testing."""
class Meta:
model = models.ApplicationDomain
domain = factory.Faker("domain_name")
application = factory.SubFactory(ApplicationFactory)
+43
View File
@@ -0,0 +1,43 @@
"""
Core application fields
"""
from logging import getLogger
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
logger = getLogger(__name__)
class SecretField(models.CharField):
"""CharField that automatically hashes secrets before saving.
Use for API keys, client secrets, or tokens that should never be stored
in plain text. Already-hashed values are preserved to prevent double-hashing.
Inspired by: https://github.com/django-oauth-toolkit/django-oauth-toolkit
"""
def pre_save(self, model_instance, add):
"""Hash the secret if not already hashed, otherwise preserve it."""
secret = getattr(model_instance, self.attname)
try:
hasher = identify_hasher(secret)
logger.debug(
"%s: %s is already hashed with %s.",
model_instance,
self.attname,
hasher,
)
except ValueError:
logger.debug(
"%s: %s is not hashed; hashing it now.", model_instance, self.attname
)
hashed_secret = make_password(secret)
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)
+1 -1
View File
@@ -41,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -0,0 +1,52 @@
# Generated by Django 5.2.6 on 2025-10-02 20:55
import core.utils
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_room_pin_code'),
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(help_text='Descriptive name for this application.', max_length=255, verbose_name='Application name')),
('active', models.BooleanField(default=True)),
('client_id', models.CharField(default=core.utils.generate_client_id, max_length=100, unique=True)),
('client_secret', core.fields.SecretField(blank=True, default=core.utils.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)),
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), blank=True, default=list, size=None)),
],
options={
'verbose_name': 'Application',
'verbose_name_plural': 'Applications',
'db_table': 'meet_application',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ApplicationDomain',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('domain', models.CharField(help_text='Email domain this application can act on behalf of.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.application')),
],
options={
'verbose_name': 'Application domain',
'verbose_name_plural': 'Application domains',
'db_table': 'meet_application_domain',
'ordering': ('domain',),
'unique_together': {('application', 'domain')},
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2025-12-29 15:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_application_and_more'),
]
operations = [
migrations.AddField(
model_name='recording',
name='options',
field=models.JSONField(blank=True, default=dict, help_text='Recording options', verbose_name='Recording options'),
),
]
+109 -1
View File
@@ -11,6 +11,7 @@ from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
@@ -18,8 +19,10 @@ from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -143,7 +146,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -573,6 +577,12 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
options = models.JSONField(
blank=True,
default=dict,
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
class Meta:
db_table = "meet_recording"
@@ -717,3 +727,101 @@ class RecordingAccess(BaseAccess):
Compute and return abilities for a given user on the recording access.
"""
return self._get_abilities(self.recording, user)
class ApplicationScope(models.TextChoices):
"""Available permission scopes for application operations."""
ROOMS_CREATE = "rooms:create", _("Create rooms")
ROOMS_LIST = "rooms:list", _("List rooms")
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
class Application(BaseModel):
"""External application for API authentication and authorization.
Represents a third-party integration or automated system that accesses
the API using OAuth2-style client credentials (client_id/client_secret).
Supports scoped permissions and optional domain restrictions for delegation.
"""
name = models.CharField(
max_length=255,
verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."),
)
active = models.BooleanField(default=True)
client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id
)
client_secret = fields.SecretField(
max_length=255,
blank=True,
default=utils.generate_client_secret,
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
)
scopes = ArrayField(
models.CharField(max_length=50, choices=ApplicationScope.choices),
default=list,
blank=True,
)
class Meta:
db_table = "meet_application"
ordering = ("-created_at",)
verbose_name = _("Application")
verbose_name_plural = _("Applications")
def __str__(self):
return f"{self.name!s}"
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
if not self.allowed_domains.exists():
return True # No domain restrictions
domain = get_domain_from_email(email)
return self.allowed_domains.filter(domain__iexact=domain).exists()
class ApplicationDomain(BaseModel):
"""Domain authorized for application delegation."""
domain = models.CharField(
max_length=253, # Max domain length per RFC 1035
validators=[
validators.DomainNameValidator(
accept_idna=False,
message=_("Enter a valid domain"),
)
],
verbose_name=_("Domain"),
help_text=_("Email domain this application can act on behalf of."),
)
application = models.ForeignKey(
"Application",
on_delete=models.CASCADE,
related_name="allowed_domains",
)
class Meta:
db_table = "meet_application_domain"
ordering = ("domain",)
verbose_name = _("Application domain")
verbose_name_plural = _("Application domains")
unique_together = [("application", "domain")]
def __str__(self):
"""Return string representation of the domain."""
return self.domain
def save(self, *args, **kwargs):
"""Save the domain after normalizing to lowercase."""
self.domain = self.domain.lower().strip()
super().save(*args, **kwargs)
@@ -16,6 +16,23 @@ from core import models
logger = logging.getLogger(__name__)
def get_recording_download_base_url() -> str:
"""Get the recording download base URL with backward compatibility."""
new_setting = settings.RECORDING_DOWNLOAD_BASE_URL
old_setting = settings.SCREEN_RECORDING_BASE_URL
if old_setting:
logger.warning(
"SCREEN_RECORDING_BASE_URL is deprecated and will be removed in a future version. "
"Please use RECORDING_DOWNLOAD_BASE_URL instead."
)
if new_setting:
return new_setting
return old_setting
class NotificationService:
"""Service for processing recordings and notifying external services."""
@@ -26,7 +43,12 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
return self._notify_user_by_email(recording)
summary_success = True
if recording.options.get("transcribe", False):
summary_success = self._notify_summary_service(recording)
email_success = self._notify_user_by_email(recording)
return email_success and summary_success
logger.error(
"Unknown recording mode %s for recording %s",
@@ -64,7 +86,7 @@ class NotificationService:
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
"link": f"{get_recording_download_base_url()}/{recording.id}",
}
has_failures = False
@@ -131,18 +153,20 @@ class NotificationService:
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
payload = {
"owner_id": str(owner_access.user.id),
"filename": recording.key,
"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"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
}
headers = {
@@ -158,9 +182,9 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
except requests.HTTPError as exc:
except requests.RequestException as exc:
logger.exception(
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
"Summary service error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
@@ -1,7 +1,11 @@
"""Recording-related LiveKit Events Service"""
# pylint: disable=no-member
from logging import getLogger
from livekit import api
from core import models, utils
logger = getLogger(__name__)
@@ -14,6 +18,27 @@ class RecordingEventsError(Exception):
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
@@ -2,6 +2,7 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from .exceptions import (
@@ -60,6 +61,15 @@ class WorkerServiceMediator:
finally:
recording.save()
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
logger.info(
"Worker started for room %s (worker ID: %s)",
recording.room,
@@ -0,0 +1,97 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class TokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (default: HS256)
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds (default: 3600)
token_type: Token type (default: Bearer)
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
self._key = secret_key
self._alg = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_access_token(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._alg,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
+41 -1
View File
@@ -2,6 +2,7 @@
# pylint: disable=no-member
import re
import uuid
from enum import Enum
from logging import getLogger
@@ -10,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models
from core import models, utils
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -92,6 +93,17 @@ class LiveKitEventsService:
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
self._filter_regex = None
if settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX:
try:
self._filter_regex = re.compile(
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX
)
except re.error:
logger.exception(
"Invalid LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX. Webhook filtering disabled."
)
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -106,6 +118,12 @@ class LiveKitEventsService:
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
room_name = data.room.name or data.egress_info.room_name
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e:
@@ -122,6 +140,20 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_egress_updated(self, data):
"""Handle 'egress_updated' event."""
egress_id = data.egress_info.egress_id
try:
recording = models.Recording.objects.get(worker_id=egress_id)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {egress_id} does not exist"
) from err
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
@@ -134,6 +166,14 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -0,0 +1,17 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Error" %}</title>
</head>
<body>
<div class="container">
<h1>{{ title|default:_("Error") }}</h1>
<p>{{ message|default:_("Something went wrong.") }}</p>
<button onclick="window.close()">{% trans "Close" %}</button>
</div>
</body>
</html>
@@ -0,0 +1,17 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Authentication Success" %}</title>
</head>
<body>
<script>
window.close();
</script>
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
</body>
</html>
@@ -345,7 +345,9 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
# Note: .save() triggers uniqueness validation queries for unique fields,
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -60,6 +60,26 @@ def test_notify_external_services_screen_recording_mode(mock_notify_email):
mock_notify_email.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode_with_transcribe(
mock_notify_email, mock_notify_summary
):
"""Test notification routing for screen recording mode with transcribe option."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING, options={"transcribe": True}
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
mock_notify_summary.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
@@ -82,6 +82,7 @@ def test_api_recordings_list_authenticated_direct(role, settings):
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"options": {},
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -95,6 +95,7 @@ def test_api_recording_retrieve_administrators(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -130,6 +131,7 @@ def test_api_recording_retrieve_owners(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -169,6 +171,7 @@ def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
@@ -209,6 +212,7 @@ def test_api_recording_retrieve_expired(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@@ -2,6 +2,7 @@
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
from unittest.mock import Mock
import pytest
@@ -33,14 +34,18 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
def test_start_recording_success(mediator, mock_worker_service):
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
mock_worker_service.start.return_value = worker_id
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED, worker_id=None
status=RecordingStatusChoices.INITIATED,
worker_id=None,
)
mediator.start(mock_recording)
@@ -55,12 +60,18 @@ def test_start_recording_success(mediator, mock_worker_service):
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_worker_errors(
mediator, mock_worker_service, error_class
mock_update_room_metadata, mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -78,6 +89,8 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
"status",
@@ -90,8 +103,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_from_forbidden_status(
mediator, mock_worker_service, status
mock_update_room_metadata, mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -105,6 +119,8 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
"""Test successful recording stop"""
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -347,7 +342,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -21,7 +21,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
from core.utils import MetadataUpdateException, NotificationError
pytestmark = pytest.mark.django_db
@@ -70,7 +70,10 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
@@ -83,13 +86,98 @@ def test_handle_egress_ended_success(mock_notify, mode, notification_type, servi
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "stopped"
@pytest.mark.parametrize(
("egress_status", "status"),
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording when metadata's update fails."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -108,9 +196,16 @@ def test_handle_egress_ended_notification_fails(mock_notify, service):
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -124,13 +219,17 @@ def test_handle_egress_ended_recording_not_found(mock_notify, service):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
@@ -141,13 +240,19 @@ def test_handle_egress_ended_recording_not_active(mock_notify, service):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
@@ -158,6 +263,9 @@ def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
@@ -343,3 +451,99 @@ def test_receive_unsupported_event(mock_receive, service):
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_no_filter_processes_all_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should process all events when filter regex is not configured."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = None
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_invalid_filter_regex_processes_all_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should process all events when filter regex is invalid (fail-safe)."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = "(abc"
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_filter_drops_non_matching_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should drop events when room name does not match filter regex."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = (
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
)
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_not_called()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_filter_processes_matching_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should process events when room name matches filter regex."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = (
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
)
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@@ -0,0 +1,569 @@
"""
Tests for external API /room endpoint
"""
# pylint: disable=W0621
from datetime import datetime, timedelta, timezone
from django.conf import settings
import jwt
import pytest
import responses
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
def generate_test_token(user, scopes):
"""Generate a valid JWT token for testing."""
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": "test-client-id",
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
}
return jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
def test_api_rooms_list_requires_authentication():
"""Listing rooms without authentication should return 401."""
client = APIClient()
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_list_missing_scope(settings):
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_LIST scope
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
def test_api_rooms_list_filters_by_user(settings):
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 2
returned_ids = [r["id"] for r in response.data["results"]]
assert str(room1.id) in returned_ids
assert str(room3.id) in returned_ids
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_RETRIEVE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:retrieve" in str(
response.data
)
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "US"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert response.data == {
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"access_level": str(room.access_level),
"url": f"http://your-application.com/{room.slug}",
"telephony": {
"enabled": True,
"phone_number": "+1-555-0100",
"pin_code": room.pin_code,
"default_country": "US",
},
}
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
)
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert "url" not in response.data
assert response.data["id"] == str(room.id)
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert "telephony" not in response.data
assert response.data["id"] == str(room.id)
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
# Missing client_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
@responses.activate
def test_resource_server_creates_user_on_first_authentication(settings):
"""New user should be created during first authentication.
Verifies that the ResourceServerBackend.get_or_create_user() creates a user
in the database when authenticating with a token from an unknown subject (sub).
This tests the user creation workflow during the OIDC introspection process.
"""
with pytest.raises(
User.DoesNotExist,
match="User matching query does not exist.",
):
User.objects.get(sub="very-specific-sub")
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
db_user = User.objects.get(sub="very-specific-sub")
assert db_user is not None
assert db_user.email is None
@responses.activate
def test_resource_server_skips_user_creation_when_auto_creation_disabled(settings):
"""Verify that ResourceServerBackend respects the user auto-creation setting.
This ensures that the OIDC introspection process respects the configuration flag
that controls whether new users should be automatically provisioned during
authentication, preventing unwanted user proliferation when auto-creation is
explicitly disabled.
"""
settings.OIDC_CREATE_USER = False
with pytest.raises(
User.DoesNotExist,
match="User matching query does not exist.",
):
User.objects.get(sub="very-specific-sub")
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
@responses.activate
def test_resource_server_authentication_successful(settings):
"""Authenticated requests should be processed and user-specific data is returned.
Verifies that once a user is authenticated via OIDC token introspection,
the API correctly identifies the user and returns only data accessible to that user
(e.g., rooms with appropriate access levels).
"""
user = UserFactory(sub="very-specific-sub")
other_user = UserFactory()
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room_user_accesses = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, users=[user]
)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(room_user_accesses.id),
}
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@responses.activate
def test_resource_server_denies_access_with_insufficient_scopes(settings):
"""Requests should be denied when the token lacks required scopes.
Verifies that the ResourceServerBackend validates token scopes during introspection
and returns 403 Forbidden when the token is missing required scopes for the endpoint.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet", # missing rooms:list scope
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
@@ -0,0 +1,392 @@
"""
Tests for external API /token endpoint
"""
# pylint: disable=W0621
import jwt
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core.factories import (
ApplicationDomainFactory,
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope, User
pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
# Store plain secret before it's hashed
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user.family@example.com",
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401."""
user = UserFactory(email="user@example.com")
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": "invalid-client-id",
"client_secret": "some-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=False)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "not-an-email",
},
format="json",
)
assert response.status_code == 400
assert "scope should be a valid email address." in str(response.data).lower()
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
application = ApplicationFactory(active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 403
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized(settings):
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "nonexistent@example.com",
},
format="json",
)
assert response.status_code == 404
assert "User not found" in str(response.data)
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "unknown@world.com",
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
user = User.objects.get(email="unknown@world.com")
assert user.sub is None
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Assert no new user was created
assert len(User.objects.all()) == 1
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@@ -0,0 +1,331 @@
"""
Unit tests for the Application and ApplicationDomain models
"""
# pylint: disable=W0613
from unittest import mock
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
import pytest
from core.factories import ApplicationDomainFactory, ApplicationFactory
from core.models import Application, ApplicationDomain, ApplicationScope
pytestmark = pytest.mark.django_db
# Application Model Tests
def test_models_application_str():
"""The str representation should be the name."""
application = ApplicationFactory(name="My Integration")
assert str(application) == "My Integration"
def test_models_application_name_maxlength():
"""The name field should be at most 255 characters."""
ApplicationFactory(name="a" * 255)
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(name="a" * 256)
assert "Ensure this value has at most 255 characters (it has 256)." in str(
excinfo.value
)
def test_models_application_active_default():
"""An application should be active by default."""
application = Application.objects.create(name="Test App")
assert application.active is True
def test_models_application_scopes_default():
"""Scopes should default to empty list."""
application = Application.objects.create(name="Test App")
assert application.scopes == []
def test_models_application_client_id_auto_generated():
"""Client ID should be automatically generated on creation."""
application = ApplicationFactory()
assert application.client_id is not None
assert len(application.client_id) > 0
def test_models_application_client_id_unique():
"""Client IDs should be unique."""
app1 = ApplicationFactory()
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(client_id=app1.client_id)
assert "Application with this Client id already exists." in str(excinfo.value)
def test_models_application_client_id_length(settings):
"""Client ID should match configured length."""
app1 = ApplicationFactory()
assert len(app1.client_id) == 40 # default value
settings.APPLICATION_CLIENT_ID_LENGTH = 20
app2 = ApplicationFactory()
assert len(app2.client_id) == 20
def test_models_application_client_secret_auto_generated():
"""Client secret should be automatically generated and hashed on creation."""
application = ApplicationFactory()
assert application.client_secret is not None
assert len(application.client_secret) > 0
def test_models_application_client_secret_hashed_on_save():
"""Client secret should be hashed when saved."""
plain_secret = "my-plain-secret"
with mock.patch(
"core.models.utils.generate_client_secret", return_value=plain_secret
):
application = ApplicationFactory(client_secret=plain_secret)
# Secret should be hashed, not plain
assert application.client_secret != plain_secret
# Should verify with check_password
assert check_password(plain_secret, application.client_secret) is True
def test_models_application_client_secret_preserves_existing_hash():
"""Re-saving should not re-hash an already hashed secret."""
application = ApplicationFactory()
original_hash = application.client_secret
# Update another field and save
application.name = "Updated Name"
application.save()
# Hash should remain unchanged
assert application.client_secret == original_hash
def test_models_application_updates_preserve_client_id():
"""Application updates should preserve existing client_id."""
application = ApplicationFactory()
original_client_id = application.client_id
application.name = "Updated Name"
application.save()
assert application.client_id == original_client_id
def test_models_application_scopes_valid_choices():
"""Only valid scope choices should be accepted."""
application = ApplicationFactory(
scopes=[
ApplicationScope.ROOMS_LIST,
ApplicationScope.ROOMS_CREATE,
ApplicationScope.ROOMS_RETRIEVE,
]
)
assert len(application.scopes) == 3
assert ApplicationScope.ROOMS_LIST in application.scopes
def test_models_application_scopes_invalid_choice():
"""Invalid scope choices should raise validation error."""
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(scopes=["invalid:scope"])
assert "is not a valid choice" in str(excinfo.value)
def test_models_application_can_delegate_email_no_restrictions():
"""Application with no domain restrictions can delegate any email."""
application = ApplicationFactory()
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("admin@anotherdomain.org") is True
def test_models_application_can_delegate_email_allowed_domain():
"""Application can delegate email from allowed domain."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@example.com") is True
def test_models_application_can_delegate_email_denied_domain():
"""Application cannot delegate email from non-allowed domain."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@other.com") is False
def test_models_application_can_delegate_email_case_insensitive():
"""Domain matching should be case-insensitive."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@EXAMPLE.COM") is True
assert application.can_delegate_email("user@Example.Com") is True
def test_models_application_can_delegate_email_multiple_domains():
"""Application with multiple allowed domains should check all."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
ApplicationDomainFactory(application=application, domain="other.org")
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("admin@other.org") is True
assert application.can_delegate_email("test@denied.com") is False
# ApplicationDomain Model Tests
def test_models_application_domain_str():
"""The str representation should be the domain."""
domain = ApplicationDomainFactory(domain="example.com")
assert str(domain) == "example.com"
def test_models_application_domain_ordering():
"""Domains should be returned ordered by domain name."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="zulu.com")
ApplicationDomainFactory(application=application, domain="alpha.com")
ApplicationDomainFactory(application=application, domain="beta.com")
domains = ApplicationDomain.objects.all()
assert domains[0].domain == "alpha.com"
assert domains[1].domain == "beta.com"
assert domains[2].domain == "zulu.com"
@pytest.mark.parametrize(
"valid_domain",
[
"example.com",
"sub.example.com",
"deep.sub.example.com",
"example-with-dash.com",
"123.example.com",
],
)
def test_models_application_domain_valid_domain(valid_domain):
"""Valid domain names should be accepted."""
ApplicationDomainFactory(domain=valid_domain)
@pytest.mark.parametrize(
"invalid_domain",
[
"not a domain",
"example..com",
"-example.com",
"example-.com",
"example.com-",
],
)
def test_models_application_domain_invalid_domain(invalid_domain):
"""Invalid domain names should raise validation error."""
with pytest.raises(ValidationError):
ApplicationDomainFactory(domain=invalid_domain)
def test_models_application_domain_lowercase_on_save():
"""Domain should be normalized to lowercase on save."""
domain = ApplicationDomainFactory(domain="EXAMPLE.COM")
assert domain.domain == "example.com"
def test_models_application_domain_strip_whitespace_on_save():
"""Domain should strip whitespace on save."""
domain = ApplicationDomainFactory(domain=" example.com ")
assert domain.domain == "example.com"
def test_models_application_domain_combined_normalization():
"""Domain should strip and lowercase in one operation."""
domain = ApplicationDomainFactory(domain=" EXAMPLE.COM ")
assert domain.domain == "example.com"
def test_models_application_domain_unique_together():
"""Same domain cannot be added twice to same application."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
with pytest.raises(ValidationError) as excinfo:
ApplicationDomainFactory(application=application, domain="example.com")
assert "Application domain with this Application and Domain already exists." in str(
excinfo.value
)
def test_models_application_domain_same_domain_different_apps():
"""Same domain can belong to different applications."""
app1 = ApplicationFactory()
app2 = ApplicationFactory()
ApplicationDomainFactory(application=app1, domain="example.com")
ApplicationDomainFactory(application=app2, domain="example.com")
assert app1.allowed_domains.count() == 1
assert app2.allowed_domains.count() == 1
def test_models_application_domain_cascade_delete():
"""Deleting application should delete its domains."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
ApplicationDomainFactory(application=application, domain="other.com")
assert ApplicationDomain.objects.count() == 2
application.delete()
assert ApplicationDomain.objects.count() == 0
def test_models_application_domain_related_name():
"""Domains should be accessible via application.allowed_domains."""
application = ApplicationFactory()
domain1 = ApplicationDomainFactory(application=application, domain="example.com")
domain2 = ApplicationDomainFactory(application=application, domain="other.com")
assert list(application.allowed_domains.all()) == [domain1, domain2]
def test_models_application_domain_filters_delegation():
"""Adding/removing domains should affect can_delegate_email."""
application = ApplicationFactory()
# No restrictions initially
assert application.can_delegate_email("user@example.com") is True
# Add domain restriction
domain = ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("user@other.com") is False
# Remove domain restriction
domain.delete()
assert application.can_delegate_email("user@other.com") is True
+55
View File
@@ -6,7 +6,10 @@ from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.addons import views as addons_views
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -17,6 +20,32 @@ router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
# - External API
external_router = DefaultRouter()
external_router.register(
"application",
external_viewsets.ApplicationViewSet,
basename="external_application",
)
# - Addons API
addons_router = DefaultRouter()
addons_router.register(
"addons/sessions",
addons_viewsets.AuthSessionViewSet,
basename="addons_auth_sessions",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -24,8 +53,34 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*addons_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
]
if settings.ADDONS_ENABLED:
urlpatterns.append(
path(
"addons/",
include(
[
path("transit/", addons_views.transit_page, name="transit_page"),
path("redirect/", addons_views.redirect_page, name="redirect_page"),
]
),
),
)
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
f"external-api/{settings.EXTERNAL_API_VERSION}/",
include(
[
*external_router.urls,
]
),
)
)
+104
View File
@@ -8,6 +8,8 @@ Utils functions used in the core app
import hashlib
import json
import random
import secrets
import string
from typing import List, Optional
from uuid import uuid4
@@ -23,6 +25,7 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -240,3 +243,104 @@ async def notify_participants(room_name: str, notification_data: dict):
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
def generate_secure_token(length: int = 30, charset: str = ALPHANUMERIC_CHARSET) -> str:
"""Generate a cryptographically secure random token.
Uses SystemRandom for proper entropy, suitable for OAuth tokens
and API credentials that must be non-guessable.
Inspired by: https://github.com/oauthlib/oauthlib/blob/master/oauthlib/common.py
Args:
length: Token length in characters (default: 30)
charset: Character set to use for generation
Returns:
Cryptographically secure random token
"""
return "".join(secrets.choice(charset) for _ in range(length))
def generate_client_id() -> str:
"""Generate a unique client ID for application authentication.
Returns:
Random client ID string
"""
return generate_secure_token(settings.APPLICATION_CLIENT_ID_LENGTH)
def generate_client_secret() -> str:
"""Generate a secure client secret for application authentication.
Returns:
Cryptographically secure client secret
"""
return generate_secure_token(settings.APPLICATION_CLIENT_SECRET_LENGTH)
def generate_room_slug():
"""Generate a random room slug in the format 'xxx-xxxx-xxx'."""
sizes = [3, 4, 3]
parts = [
"".join(secrets.choice(string.ascii_lowercase) for _ in range(size))
for size in sizes
]
return "-".join(parts)
Binary file not shown.
+225 -79
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,121 +17,177 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sitzungs-ID ist erforderlich."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sitzung nicht gefunden oder abgelaufen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ungültiger Sitzungsstatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentifizierung erforderlich."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Keine aktive Sitzung gefunden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ungültige oder abgelaufene Sitzung."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:294
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Mitglied"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Aktiv"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "ID"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "erstellt am"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -139,67 +195,67 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "Sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:154
#: core/models.py:158
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:159
#: core/models.py:163
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:161
#: core/models.py:165
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:163
#: core/models.py:167
msgid "short name"
msgstr "Kurzname"
#: core/models.py:169
#: core/models.py:173
msgid "language"
msgstr "Sprache"
#: core/models.py:170
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:176
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:179
#: core/models.py:183
msgid "device"
msgstr "Gerät"
#: core/models.py:181
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:184
#: core/models.py:188
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:186
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:189
#: core/models.py:193
msgid "active"
msgstr "aktiv"
#: core/models.py:192
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -207,66 +263,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:205
#: core/models.py:209
msgid "user"
msgstr "Benutzer"
#: core/models.py:206
#: core/models.py:210
msgid "users"
msgstr "Benutzer"
#: core/models.py:265
#: core/models.py:269
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
#: core/models.py:270
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:320
#: core/models.py:324
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:321
#: core/models.py:325
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:327
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:383
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:384
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:391
#: core/models.py:395
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:392
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Raum"
#: core/models.py:399
#: core/models.py:403
msgid "Rooms"
msgstr "Räume"
#: core/models.py:563
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:565
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -275,43 +331,108 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:573
#: core/models.py:577
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:574
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:580
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:590
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:581
#: core/models.py:591
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:689
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:690
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:696
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:702
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:708
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/recording/event/notification.py:94
#: core/models.py:735
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:736
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:738
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:739
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:752
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:774
msgid "Application"
msgstr "Anwendung"
#: core/models.py:775
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:801
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:814
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:815
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -320,6 +441,30 @@ msgstr "Ihre Aufzeichnung ist bereit"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fehler"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Etwas ist schiefgelaufen."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Schließen"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentifizierung erfolgreich"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Falls es sich nicht schließt"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -401,7 +546,8 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
"Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -438,18 +584,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:169
msgid "English"
msgstr "Englisch"
#: meet/settings.py:164
#: meet/settings.py:170
msgid "French"
msgstr "Französisch"
#: meet/settings.py:165
#: meet/settings.py:171
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:166
#: meet/settings.py:172
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+226 -78
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,119 +17,175 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Session ID is required."
#: core/admin.py:39
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session not found or expired."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Invalid session state."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentication required."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "No active session found."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Invalid or expired session."
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
#: core/admin.py:42
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "No owner"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:294
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Member"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Owner"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Active"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "created on"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "updated on"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -137,67 +193,67 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:154
#: core/models.py:158
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:159
#: core/models.py:163
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:161
#: core/models.py:165
msgid "full name"
msgstr "full name"
#: core/models.py:163
#: core/models.py:167
msgid "short name"
msgstr "short name"
#: core/models.py:169
#: core/models.py:173
msgid "language"
msgstr "language"
#: core/models.py:170
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:176
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:179
#: core/models.py:183
msgid "device"
msgstr "device"
#: core/models.py:181
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:184
#: core/models.py:188
msgid "staff status"
msgstr "staff status"
#: core/models.py:186
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:189
#: core/models.py:193
msgid "active"
msgstr "active"
#: core/models.py:192
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -205,63 +261,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:205
#: core/models.py:209
msgid "user"
msgstr "user"
#: core/models.py:206
#: core/models.py:210
msgid "users"
msgstr "users"
#: core/models.py:265
#: core/models.py:269
msgid "Resource"
msgstr "Resource"
#: core/models.py:266
#: core/models.py:270
msgid "Resources"
msgstr "Resources"
#: core/models.py:320
#: core/models.py:324
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:321
#: core/models.py:325
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:327
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:383
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:384
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:391
#: core/models.py:395
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:392
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:398 core/models.py:552
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Room"
#: core/models.py:399
#: core/models.py:403
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:563
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -269,43 +325,111 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:573
#: core/models.py:577
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:574
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:580
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:590
msgid "Recording"
msgstr "Recording"
#: core/models.py:581
#: core/models.py:591
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:689
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:690
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:696
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:702
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:708
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/recording/event/notification.py:94
#: core/models.py:735
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:736
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:738
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:739
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:752
msgid "Application name"
msgstr "Application name"
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:774
msgid "Application"
msgstr "Application"
#: core/models.py:775
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:801
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:814
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:815
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -314,6 +438,30 @@ msgstr "Your recording is ready"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Error"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Something went wrong."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Close"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentication Success"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session stored successfully. This window will close automatically."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "If it doesn't close"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -433,18 +581,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:169
msgid "English"
msgstr "English"
#: meet/settings.py:164
#: meet/settings.py:170
msgid "French"
msgstr "French"
#: meet/settings.py:165
#: meet/settings.py:171
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:166
#: meet/settings.py:172
msgid "German"
msgstr "German"
Binary file not shown.
+226 -79
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,123 +17,179 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "L'identifiant de session est requis."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session introuvable ou expirée."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "État de session invalide."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentification requise."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Aucune session active trouvée."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Session invalide ou expirée."
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:294
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Membre"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Actif"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "créé le"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -141,67 +197,67 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:154
#: core/models.py:158
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:159
#: core/models.py:163
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:161
#: core/models.py:165
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
#: core/models.py:167
msgid "short name"
msgstr "nom court"
#: core/models.py:169
#: core/models.py:173
msgid "language"
msgstr "langue"
#: core/models.py:170
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:176
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:179
#: core/models.py:183
msgid "device"
msgstr "appareil"
#: core/models.py:181
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:184
#: core/models.py:188
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:186
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:189
#: core/models.py:193
msgid "active"
msgstr "actif"
#: core/models.py:192
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,65 +265,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:205
#: core/models.py:209
msgid "user"
msgstr "utilisateur"
#: core/models.py:206
#: core/models.py:210
msgid "users"
msgstr "utilisateurs"
#: core/models.py:265
#: core/models.py:269
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
#: core/models.py:270
msgid "Resources"
msgstr "Ressources"
#: core/models.py:320
#: core/models.py:324
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:321
#: core/models.py:325
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:327
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:383
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:384
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:391
#: core/models.py:395
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:392
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Salle"
#: core/models.py:399
#: core/models.py:403
msgid "Rooms"
msgstr "Salles"
#: core/models.py:563
#: core/models.py:567
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:565
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -275,43 +331,109 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:573
#: core/models.py:577
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:574
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:580
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:590
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:581
#: core/models.py:591
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:689
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:690
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:696
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:702
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:708
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/recording/event/notification.py:94
#: core/models.py:735
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:736
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:738
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:739
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:752
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:774
msgid "Application"
msgstr "Application"
#: core/models.py:775
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:801
msgid "Domain"
msgstr "Domaine"
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:814
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:815
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -320,6 +442,30 @@ msgstr "Votre enregistrement est prêt"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Erreur"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Une erreur s'est produite."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Fermer"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentification réussie"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Si elle ne se ferme pas"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -401,7 +547,8 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
"les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -438,18 +585,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:169
msgid "English"
msgstr "Anglais"
#: meet/settings.py:164
#: meet/settings.py:170
msgid "French"
msgstr "Français"
#: meet/settings.py:165
#: meet/settings.py:171
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:166
#: meet/settings.py:172
msgid "German"
msgstr "Allemand"
Binary file not shown.
+225 -78
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,120 +17,176 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sessie-ID is vereist."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sessie niet gevonden of verlopen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ongeldige sessiestatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authenticatie vereist."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Geen actieve sessie gevonden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ongeldige of verlopen sessie."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:294
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Lid"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Actief"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -138,66 +194,67 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:154
#: core/models.py:158
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:159
#: core/models.py:163
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:161
#: core/models.py:165
msgid "full name"
msgstr "volledige naam"
#: core/models.py:163
#: core/models.py:167
msgid "short name"
msgstr "korte naam"
#: core/models.py:169
#: core/models.py:173
msgid "language"
msgstr "taal"
#: core/models.py:170
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:176
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:179
#: core/models.py:183
msgid "device"
msgstr "apparaat"
#: core/models.py:181
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:184
#: core/models.py:188
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:186
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:189
#: core/models.py:193
msgid "active"
msgstr "actief"
#: core/models.py:192
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -205,64 +262,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:205
#: core/models.py:209
msgid "user"
msgstr "gebruiker"
#: core/models.py:206
#: core/models.py:210
msgid "users"
msgstr "gebruikers"
#: core/models.py:265
#: core/models.py:269
msgid "Resource"
msgstr "Bron"
#: core/models.py:266
#: core/models.py:270
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:320
#: core/models.py:324
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:321
#: core/models.py:325
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:327
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:383
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:384
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:391
#: core/models.py:395
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:392
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Ruimte"
#: core/models.py:399
#: core/models.py:403
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:563
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -270,43 +327,108 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:573
#: core/models.py:577
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:574
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:580
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:590
msgid "Recording"
msgstr "Opname"
#: core/models.py:581
#: core/models.py:591
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:689
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:690
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:696
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:702
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:708
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/notification.py:94
#: core/models.py:735
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:736
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:738
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:739
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:752
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:774
msgid "Application"
msgstr "Applicatie"
#: core/models.py:775
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:801
msgid "Domain"
msgstr "Domein"
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:814
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:815
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -315,6 +437,30 @@ msgstr "Je opname is klaar"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fout"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Er is iets misgegaan."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Sluiten"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authenticatie geslaagd"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Als het niet sluit"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -396,7 +542,8 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
"Het delen van de opname via een link is nog niet beschikbaar. Alleen "
"organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -433,18 +580,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:169
msgid "English"
msgstr "Engels"
#: meet/settings.py:164
#: meet/settings.py:170
msgid "French"
msgstr "Frans"
#: meet/settings.py:165
#: meet/settings.py:171
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:166
#: meet/settings.py:172
msgid "German"
msgstr "Duits"
+191 -3
View File
@@ -10,6 +10,8 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
# pylint: disable=too-many-lines
import json
from os import path
from socket import gethostbyname, gethostname
@@ -69,6 +71,10 @@ class Base(Configuration):
USE_SWAGGER = False
API_VERSION = "v1.0"
EXTERNAL_API_VERSION = "v1.0"
EXTERNAL_API_ENABLED = values.BooleanValue(
False, environ_name="EXTERNAL_API_ENABLED", environ_prefix=None
)
DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR")
@@ -284,6 +290,11 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"addons_auth_sessions": values.Value(
default="150/minute",
environ_name="ADDONS_AUTH_SESSION_THROTTLE_RATES",
environ_prefix=None,
),
},
}
@@ -322,6 +333,11 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
),
"idle_disconnect_warning_delay": values.PositiveIntegerValue(
None,
environ_name="FRONTEND_IDLE_DISCONNECT_WARNING_DELAY",
environ_prefix=None,
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
@@ -331,12 +347,12 @@ class Base(Configuration):
"use_proconnect_button": values.BooleanValue(
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
),
"transcript": values.DictValue(
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
),
"manifest_link": values.Value(
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
),
"transcription_destination": values.Value(
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
),
}
# Mail
@@ -394,7 +410,15 @@ class Base(Configuration):
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
@@ -418,12 +442,16 @@ class Base(Configuration):
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
@@ -484,6 +512,45 @@ class Base(Configuration):
environ_prefix=None,
)
# OIDC Resource Server Backend
OIDC_RS_BACKEND_CLASS = "core.external_api.authentication.ResourceServerBackend"
OIDC_RS_CLIENT_ID = values.Value(
"meet", environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = SecretFileValue(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_RS_AUDIENCE_CLAIM = values.Value(
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
)
OIDC_RS_SIGNING_ALGO = values.Value(
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
default=["lasuite_meet"],
environ_name="OIDC_RS_SCOPES",
environ_prefix=None,
)
OIDC_RS_PRIVATE_KEY_STR = SecretFileValue(
environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_prefix=None
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None
)
OIDC_RS_SCOPES_PREFIX = values.Value(
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
@@ -513,6 +580,10 @@ class Base(Configuration):
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
# Regex to filter webhook events by room name. Only matching events are processed.
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = values.Value(
None, environ_name="LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX", environ_prefix=None
)
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
)
@@ -568,6 +639,9 @@ class Base(Configuration):
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
RECORDING_DOWNLOAD_BASE_URL = values.Value(
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
@@ -664,6 +738,119 @@ class Base(Configuration):
environ_prefix=None,
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
environ_name="APPLICATION_CLIENT_ID_LENGTH",
environ_prefix=None,
)
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
128,
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
environ_prefix=None,
)
APPLICATION_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="APPLICATION_JWT_SECRET_KEY", environ_prefix=None
)
APPLICATION_JWT_ALG = values.Value(
"HS256",
environ_name="APPLICATION_JWT_ALG",
environ_prefix=None,
)
APPLICATION_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="APPLICATION_JWT_ISSUER",
environ_prefix=None,
)
APPLICATION_JWT_AUDIENCE = values.Value(
None,
environ_name="APPLICATION_JWT_AUDIENCE",
environ_prefix=None,
)
APPLICATION_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="APPLICATION_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
APPLICATION_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="APPLICATION_JWT_TOKEN_TYPE",
environ_prefix=None,
)
APPLICATION_BASE_URL = values.Value(
None,
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# Allows third-party platforms to create users with email-only identification.
# Required for external integrations, but fragile due to deferred user reconciliation
# on sub. Enable it with care /!\
APPLICATION_ALLOW_USER_CREATION = values.BooleanValue(
False,
environ_name="APPLICATION_ALLOW_USER_CREATION",
environ_prefix=None,
)
# Addons
ADDONS_ENABLED = values.BooleanValue(
False,
environ_name="ADDONS_ENABLED",
environ_prefix=None,
)
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
32,
environ_name="ADDONS_SESSION_ID_LENGTH",
environ_prefix=None,
)
# Used in cache key generation
ADDONS_SESSION_KEY_PREFIX = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_PREFIX",
environ_prefix=None,
)
# Used as the Django session key in transit page
ADDONS_SESSION_KEY_AUTH = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_AUTH",
environ_prefix=None,
)
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
)
ADDONS_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
)
ADDONS_JWT_ALG = values.Value(
"HS256",
environ_name="ADDONS_JWT_ALG",
environ_prefix=None,
)
ADDONS_SCOPES = values.Value(
"rooms:create rooms:list",
environ_name="ADDONS_SCOPES",
environ_prefix=None,
)
ADDONS_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="ADDONS_JWT_ISSUER",
environ_prefix=None,
)
ADDONS_JWT_AUDIENCE = values.Value(
None,
environ_name="ADDONS_JWT_AUDIENCE",
environ_prefix=None,
)
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
ADDONS_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="ADDONS_JWT_TOKEN_TYPE",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -782,6 +969,7 @@ class Test(Base):
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
+1
View File
@@ -15,6 +15,7 @@ from drf_spectacular.views import (
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("core.urls")),
path("", include("lasuite.oidc_resource_server.urls")),
]
if settings.DEBUG:
+31 -31
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.38"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.38.42",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"boto3==1.40.69",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite[all]==0.0.10",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.6",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"jsonschema==4.25.1",
"markdown==3.10",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"whitenoise==6.9.0",
"requests==2.32.5",
"sentry-sdk==2.43.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.3",
"aiohttp==3.12.13",
"livekit-api==1.0.7",
"aiohttp==3.13.3",
]
[project.urls]
@@ -68,21 +68,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.3.0",
"pyfakefs==5.9.1",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest==9.0.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.12.0",
"types-requests==2.32.4.20250611",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]
+6 -1
View File
@@ -38,7 +38,12 @@ RUN npm run build
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
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
USER nginx
+16
View File
@@ -6,6 +6,22 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>

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