Compare commits

..

8 Commits

Author SHA1 Message Date
lebaudantoine 0ee050ab37 wip use a transit token 2026-04-02 17:43:25 +02:00
lebaudantoine f6ec01f3c8 wip rely on cookie for the session service 2026-03-30 23:24:16 +02:00
lebaudantoine 3f77a9d884 wip enforce csp 2026-03-30 16:29:56 +02:00
lebaudantoine c6de0041da wip move the addin in a dedicated babel project 2026-03-16 22:49:47 +01:00
lebaudantoine 0c98dee3ef wip introduce a wip outlook add-in
not production ready
2026-03-16 19:11:06 +01:00
lebaudantoine 768b983f8d fixup! wip introduce add-in endpoints and views 2026-03-16 15:27:51 +01:00
lebaudantoine 3a63b1e410 fixup! wip introduce add-in endpoints and views 2026-02-24 21:30:06 +01:00
lebaudantoine 65b194d432 wip introduce add-in endpoints and views 2026-02-24 21:15:21 +01:00
299 changed files with 21501 additions and 17927 deletions
+6 -8
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__ **/__pycache__
**/*.pyc **/*.pyc
venv venv
**/.venv .venv
# System-specific files # System-specific files
.DS_Store .DS_Store
@@ -24,15 +24,13 @@ data
.cache .cache
.circleci .circleci
.git .git
.vscode
.iml .iml
.idea
db.sqlite3 db.sqlite3
.mypy_cache
.pylint.d .pylint.d
.pytest_cache
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Frontend # Frontend
**/node_modules node_modules
-15
View File
@@ -12,9 +12,6 @@ on:
branches: branches:
- 'main' - 'main'
permissions:
contents: read
env: env:
DOCKER_USER: 1001:127 DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -23,8 +20,6 @@ env:
jobs: jobs:
build-and-push-backend: build-and-push-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -68,8 +63,6 @@ jobs:
build-and-push-frontend-generic: build-and-push-frontend-generic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -114,8 +107,6 @@ jobs:
build-and-push-frontend-dinum: build-and-push-frontend-dinum:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -160,8 +151,6 @@ jobs:
build-and-push-summary: build-and-push-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -208,8 +197,6 @@ jobs:
build-and-push-agents: build-and-push-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- -
name: Checkout repository name: Checkout repository
@@ -255,8 +242,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
notify-argocd: notify-argocd:
permissions:
contents: read
needs: needs:
- build-and-push-frontend-generic - build-and-push-frontend-generic
- build-and-push-frontend-dinum - build-and-push-frontend-dinum
+12 -55
View File
@@ -124,17 +124,15 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7 - name: Install development dependencies
- name: Install the project run: pip install --user .[dev]
run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run ruff format . --diff run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run ruff check . run: ~/.local/bin/ruff check .
- name: Lint code with pylint - name: Lint code with pylint
run: uv run pylint meet demo core run: ~/.local/bin/pylint meet demo core
lint-agents: lint-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -232,7 +230,6 @@ jobs:
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com OIDC_OP_URL: https://oidc.example.com
MEDIA_BASE_URL: http://localhost:8083
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -281,10 +278,10 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7
- name: Install the dependencies - name: Install development dependencies
run: uv sync --locked --all-extras run: pip install --user .[dev]
- name: Install gettext (required to compile messages) - name: Install gettext (required to compile messages)
run: | run: |
@@ -292,50 +289,10 @@ jobs:
sudo apt-get install -y gettext sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project - name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages run: python manage.py compilemessages
- name: Run tests - name: Run tests
run: uv run pytest -n 2 run: ~/.local/bin/pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
APP_API_TOKEN: "test-api-token"
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front: lint-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-29
View File
@@ -1,29 +0,0 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
-1
View File
@@ -31,7 +31,6 @@ MANIFEST
# Translations # Translations # Translations # Translations
*.pot *.pot
*.mo
# Environments # Environments
.env .env
+1 -106
View File
@@ -8,106 +8,9 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ⬆️(dependencies) update python dependencies
### Fixed
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
- 🐛(backend) fix regression in update-participant endpoint #1204
## [1.12.0] - 2026-03-24
### Changed
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
- ♿️(frontend) fix more tools heading hierarchy #1181
- ♿️(fronted) improve button descriptions for More tools actions #1184
- 💄(spinner) enforce spinner height #1183
- 💄(custom-background) add upload indicator with preview #1183
- ♿️(backend) improve logo accessibility in recording email notification #1092
- ♿️(summary) improve accessibility of transcription download link #1187
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
- ⬆️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
- ⬆️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
- ⬆️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
### Fixed
- 🐛(frontend) disable personal custom background while deleting #1183
- 🐛(frontend) auto-select new custom background when not logged in #1183
- 🐛(frontend) fix device selection not applying during conference #1156
## [1.11.0] - 2026-03-19
### Added
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿️(frontend) improve chat toast a11y for screen readers #1109
- ♿️(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
- 📌(agents) unpin OpenSSL and related dependencies #1167
- ♿️(frontend) add caption font and background color customization #1122
### Fixed
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
- ♿(frontend) dynamic tab title when connected to meeting #1060
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
### Added
- ✨(backend) add file upload feature #1030
## [1.9.0] - 2026-03-02
### Added ### Added
- 👷(docker) add arm64 platform support for image builds - 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed ### Changed
@@ -118,16 +21,8 @@ and this project adheres to
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009 - 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010 - 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011 - ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012 - ♿️(a11y) fix focus ring on tab container components
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20 ## [1.8.0] - 2026-02-20
+23 -41
View File
@@ -13,28 +13,14 @@ RUN apk update && \
# ---- Back-end builder image ---- # ---- Back-end builder image ----
FROM base AS back-builder FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1 # Copy required python dependencies
ENV UV_LINK_MODE=copy COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter RUN mkdir /install && \
# across both images. If using a managed Python version, it needs to be pip install --prefix=/install .
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ---- # ---- mails ----
FROM node:20 AS mail-builder FROM node:20 AS mail-builder
@@ -44,7 +30,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \ RUN yarn install --frozen-lockfile && \
yarn build yarn build
# ---- static link collector ---- # ---- static link collector ----
@@ -53,20 +39,19 @@ ARG MEET_STATIC_ROOT=/data/static
RUN apk add \ RUN apk add \
pango \ pango \
libmagic \
rdfind rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic # collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the # Replace duplicated file by a symlink to decrease the overall size of the
# final image # final image
@@ -83,7 +68,6 @@ RUN apk --no-cache add \
gettext \ gettext \
libffi-dev \ libffi-dev \
pango \ pango \
libmagic \
shared-mime-info shared-mime-info
@@ -95,17 +79,14 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint). # docker user (see entrypoint).
RUN chmod g=u /etc/passwd RUN chmod g=u /etc/passwd
# Copy the application from the builder # Copy installed python dependencies
COPY --from=back-builder /app /app COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that # We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group # creates a user on-the-fly with the container user ID (see USER) and root group
# ID. # ID.
@@ -120,9 +101,10 @@ USER root:root
# Install psql # Install psql
RUN apk add postgresql-client RUN apk add postgresql-client
# Install development dependencies # Uninstall Meet and re-install it in editable mode along with development
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \ # dependencies
uv sync --all-extras --locked RUN pip uninstall -y meet
RUN pip install -e .[dev]
# Restore the un-privileged user running the application # Restore the un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
@@ -131,7 +113,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services # Target database host (e.g. database engine following docker compose services
# name) & port # name) & port
ENV DB_HOST=postgresql \ ENV DB_HOST=postgresql \
DB_PORT=5432 DB_PORT=5432
# Run django development server # Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+9 -18
View File
@@ -23,10 +23,9 @@
# ============================================================================== # ==============================================================================
# VARIABLES # VARIABLES
ESC := $(shell printf '\033') BOLD := \033[1m
BOLD := $(ESC)[1m RESET := \033[0m
RESET := $(ESC)[0m GREEN := \033[1;32m
GREEN := $(ESC)[1;32m
# -- Database # -- Database
@@ -86,8 +85,7 @@ bootstrap: \
demo \ demo \
back-i18n-compile \ back-i18n-compile \
mails-install \ mails-install \
mails-build \ mails-build
run
.PHONY: bootstrap .PHONY: bootstrap
# -- Docker/compose # -- Docker/compose
@@ -114,8 +112,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs .PHONY: logs
run-backend: ## start only the backend application and all needed services run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans @$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..." @echo "Wait for postgresql to be up..."
@$(WAIT_DB) @$(WAIT_DB)
.PHONY: run-backend .PHONY: run-backend
@@ -191,7 +188,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests test: ## run project tests
@$(MAKE) test-back-parallel @$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test .PHONY: test
test-back: ## run back-end tests test-back: ## run back-end tests
@@ -204,11 +200,6 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}} bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel .PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project. makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)" @echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql @$(COMPOSE) up -d postgresql
@@ -229,7 +220,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser .PHONY: superuser
back-i18n-compile: ## compile the gettext files back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore=".venv/**/*" @$(MANAGE) compilemessages --ignore="venv/**/*"
.PHONY: back-i18n-compile .PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n back-i18n-generate: ## create the .pot files used for i18n
@@ -360,13 +351,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind start-tilt: ## start the kubernetes cluster using kind
tilt up --namespace=meet -f ./bin/Tiltfile tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
+14 -58
View File
@@ -2,6 +2,7 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%"> <img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/"> <a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt=""> <img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
@@ -27,13 +28,11 @@
## La Suite Meet: Simple Video Conferencing ## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/). Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features ### Features
- Optimized for stability in large meetings (+100 p.) - Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams - Support for multiple screen sharing streams
- Non-persistent, secure chat - Non-persistent, secure chat
- End-to-end encryption with hybrid key distribution - End-to-end encryption (coming soon)
- Meeting recording - Meeting recording
- Meeting transcription & Summary (currently in beta) - Meeting transcription & Summary (currently in beta)
- Telephony integration - Telephony integration
@@ -46,54 +45,6 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- selective subscription - selective subscription
- SVC codecs (VP9, AV1) - SVC codecs (VP9, AV1)
### End-to-end encryption
La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available:
#### Basic encryption
- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`)
- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
- Sharing the meeting link shares the encryption key
- No account or onboarding required
- Security depends on keeping the link private
#### Advanced encryption
- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe
- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
- All participants must complete encryption onboarding (key generation + backup) before joining
- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
**Frame encryption (both modes):**
- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
- Only the media payload is encrypted, with a per-frame random nonce
- The server (LiveKit SFU) only forwards encrypted data it cannot read
**Trust levels (advanced mode):**
| Badge | Level | Description |
|-------|-------|-------------|
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
**Security guarantees:**
- Encrypted rooms enforce restricted access (lobby approval required)
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
**Configuration:**
```env
ENCRYPTION_ENABLED=true
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
```
When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr). La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
@@ -112,6 +63,7 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy) - [Philosophy](#philosophy)
- [Open source](#open-source) - [Open source](#open-source)
## Get started ## Get started
## Docs ## Docs
@@ -130,15 +82,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon. > Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances #### 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🙏 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 | | 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.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.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 | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing ## Contributing
@@ -148,6 +100,7 @@ We <3 contributions of any kind, big and small:
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)) - 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) - 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)
## Philosophy ## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics. Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -156,6 +109,7 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority. Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source ## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE). Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -167,6 +121,7 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr). Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞 ## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors"> <a href="https://github.com/suitenumerique/meet/graphs/contributors">
@@ -182,3 +137,4 @@ This project is tested with BrowserStack.
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique). Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html). Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
+4 -4
View File
@@ -18,6 +18,7 @@ docker_build(
'localhost:5001/meet-backend:latest', 'localhost:5001/meet-backend:latest',
context='..', context='..',
dockerfile='../Dockerfile', dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'], only=['./src/backend', './src/mail', './docker'],
target = 'backend-production', target = 'backend-production',
live_update=[ live_update=[
@@ -33,6 +34,7 @@ clean_old_images('localhost:5001/meet-backend')
docker_build( docker_build(
'localhost:5001/meet-frontend-dinum:latest', 'localhost:5001/meet-frontend-dinum:latest',
context='..', context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile', dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'], only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production', target = 'frontend-production',
@@ -57,6 +59,7 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build( docker_build(
'localhost:5001/meet-summary:latest', 'localhost:5001/meet-summary:latest',
context='../src/summary', context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile', dockerfile='../src/summary/Dockerfile',
only=['.'], only=['.'],
target = 'production', target = 'production',
@@ -69,6 +72,7 @@ clean_old_images('localhost:5001/meet-summary')
docker_build( docker_build(
'localhost:5001/meet-agents:latest', 'localhost:5001/meet-agents:latest',
context='../src/agents', context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile', dockerfile='../src/agents/Dockerfile',
only=['.'], only=['.'],
target = 'production', target = 'production',
@@ -99,11 +103,7 @@ k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .
k8s_resource('minio-bucket', resource_deps=['minio']) k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server']) k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend']) k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server']) k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql']) k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate']) k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+2 -4
View File
@@ -58,7 +58,7 @@ services:
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' && /usr/bin/mc admin 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 && /usr/bin/mc admin service restart meet --wait --json &&
sleep 15 && sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" && /usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;" exit 0;"
app-dev: app-dev:
@@ -80,11 +80,11 @@ services:
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
- /app/.venv
depends_on: depends_on:
- postgresql - postgresql
- mailcatcher - mailcatcher
- redis - redis
- nginx
- livekit - livekit
- createbuckets - createbuckets
- createwebhook - createwebhook
@@ -106,7 +106,6 @@ services:
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
- /app/.venv
depends_on: depends_on:
- app-dev - app-dev
@@ -149,7 +148,6 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on: depends_on:
- keycloak - keycloak
- app-dev
networks: networks:
- resource-server - resource-server
- default - default
+3 -20
View File
@@ -60,7 +60,7 @@
}, },
{ {
"username": "user-e2e-chromium", "username": "user-e2e-chromium",
"email": "user.test@chromium.test", "email": "user@chromium.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Chromium", "lastName": "Chromium",
"enabled": "true", "enabled": "true",
@@ -74,7 +74,7 @@
}, },
{ {
"username": "user-e2e-webkit", "username": "user-e2e-webkit",
"email": "user.test@webkit.test", "email": "user@webkit.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Webkit", "lastName": "Webkit",
"enabled": "true", "enabled": "true",
@@ -88,7 +88,7 @@
}, },
{ {
"username": "user-e2e-firefox", "username": "user-e2e-firefox",
"email": "user.test@firefox.test", "email": "user@firefox.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Firefox", "lastName": "Firefox",
"enabled": "true", "enabled": "true",
@@ -845,23 +845,6 @@
"offline_access", "offline_access",
"microprofile-jwt" "microprofile-jwt"
] ]
},
{
"clientId": "encryption",
"name": "Encryption Service",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://encryption.localhost:7200/auth/callback"
],
"webOrigins": [
"http://encryption.localhost:7200",
"http://data.encryption.localhost:7200"
],
"protocol": "openid-connect",
"fullScopeAllowed": true
} }
], ],
"clientScopes": [ "clientScopes": [
+18
View File
@@ -38,6 +38,20 @@ COPY ./docker/dinum-frontend/assets/ \
COPY ./docker/dinum-frontend/fonts/ \ COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/ ./dist/assets/fonts/
# ---- Outlook add-in builder image ----
FROM node:20-alpine AS outlook-addin-builder
WORKDIR /home/outlook-addin
COPY ./src/addins/outlook-addin/package.json ./package.json
COPY ./src/addins/outlook-addin/package-lock.json ./package-lock.json
RUN npm ci
COPY ./src/addins/outlook-addin/ .
RUN npx webpack --mode production
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
@@ -59,6 +73,10 @@ COPY --from=meet-builder \
/home/frontend/dist \ /home/frontend/dist \
/usr/share/nginx/html /usr/share/nginx/html
COPY --from=outlook-addin-builder \
/home/outlook-addin/dist \
/usr/share/nginx/html/outlook-addin
COPY ./src/frontend/default.conf /etc/nginx/conf.d COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -4,47 +4,10 @@ server {
server_name localhost; server_name localhost;
charset utf-8; charset utf-8;
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
add_header Content-Disposition "attachment";
}
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / { location / {
proxy_pass http://keycloak:8080; proxy_pass http://keycloak:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
} }
} }
+4 -3
View File
@@ -61,10 +61,11 @@ services:
`docker compose up -d` `docker compose up -d`
``` ```
Your keycloak instance is now available on https://id.yourdomain.tld Your keycloak instance is now available on https://doc.yourdomain.tld
> [!CAUTION] > [!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). > 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 ## Creating an OIDC Client for Meet Application
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
3. Enter the name of the realm - `meet`. 3. Enter the name of the realm - `meet`.
4. Click "Create". 4. Click "Create".
### Step 2: Create a New Client #### Step 2: Create a New Client
1. Navigate to the "Clients" tab. 1. Navigate to the "Clients" tab.
2. Click on the "Create client" button. 2. Click on the "Create client" button.
@@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - 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". 1. Click "Save".
### Step 3: Get Client Credentials #### Step 3: Get Client Credentials
1. Go to the "Credentials" tab. 1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret. 2. Copy the client ID (`meet` in this example) and the client secret.
+3 -3
View File
@@ -68,13 +68,13 @@ backend:
python manage.py createsuperuser --email admin@example.com --password admin python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never restartPolicy: Never
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts: extraVolumeMounts:
- name: certs - name: certs
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes: extraVolumes:
- name: certs - name: certs
configMap: configMap:
+2 -2
View File
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`. Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file. 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) To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE] > [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports. > In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
### Meet ### Meet
+7 -7
View File
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet $ kubectl create namespace meet
``` ```
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed. If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
``` ```
$ kubectl config set-context --current --namespace=meet $ kubectl config set-context --current --namespace=meet
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml $ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until $ #wait until
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email" OIDC_RP_SCOPES: "openid email"
``` ```
You can find these values in **examples/helm/keycloak.values.yaml** You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values ### Find livekit server connexion values
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
Livekit need a redis (and meet too) so we will start by deploying a redis : Livekit need a redis (and meet too) so we will start by deploying a redis :
``` ```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml $ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m keycloak-0 1/1 Running 0 26m
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
``` ```
$ helm repo add livekit https://helm.livekit.io $ helm repo add livekit https://helm.livekit.io
$ helm repo update $ helm repo update
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml $ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 30m keycloak-0 1/1 Running 0 30m
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows: LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
``` ```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml $ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 45m keycloak-0 1/1 Running 0 45m
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
``` ```
$ helm repo add meet https://suitenumerique.github.io/meet/ $ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo update $ helm repo update
$ helm install meet meet/meet -f examples/helm/meet.values.yaml $ helm install meet meet/meet -f examples/meet.values.yaml
``` ```
## Test your deployment ## Test your deployment
-1
View File
@@ -190,7 +190,6 @@ paths:
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/rooms/:
post: post:
tags: tags:
- Rooms - Rooms
-1
View File
@@ -113,7 +113,6 @@ paths:
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/rooms/:
post: post:
tags: tags:
- Rooms - Rooms
-3
View File
@@ -23,12 +23,9 @@ MEET_BASE_URL="http://localhost:8072"
# Media # Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC # OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
-1
View File
@@ -2,7 +2,6 @@
Gitlint extra rule to validate that the message title is of the form Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>" "<gitmoji>(<scope>) <subject>"
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
import re import re
-6
View File
@@ -36,12 +36,6 @@
"matchPackageNames": ["django"], "matchPackageNames": ["django"],
"allowedVersions": "<6.0.0" "allowedVersions": "<6.0.0"
}, },
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
+8
View File
@@ -0,0 +1,8 @@
{
"plugins": [
"office-addins"
],
"extends": [
"plugin:office-addins/recommended"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": false
}
}
],
]
}
+173
View File
@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.1.0</Version>
<ProviderName>Visio</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Visio"/>
<Description DefaultValue="Ajoutez facilement un lien de réunion Visio à vos emails et événements Outlook."/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
<AppDomain>https://meet.127.0.0.1.nip.io</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
<RequestedHeight>250</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<!-- Button 1: Generate meeting link (function call) -->
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<!-- Button 2: Open settings taskpane -->
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="Visio"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir le panneau"/>
<bt:String id="GenerateLink.Label" DefaultValue="Générer un lien de réunion"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre le panneau de connexion Visio."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion Visio et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion Visio."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"name": "office-addin-taskpane-js",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
},
"license": "MIT",
"config": {
"app_to_debug": "outlook",
"app_type_to_debug": "desktop",
"dev_server_port": 3000
},
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"dev-server": "webpack serve --mode development",
"lint": "office-addin-lint check",
"lint:fix": "office-addin-lint fix",
"prettier": "office-addin-lint prettier",
"signin": "office-addin-dev-settings m365-account login",
"signout": "office-addin-dev-settings m365-account logout",
"start": "office-addin-debugging start manifest.xml",
"stop": "office-addin-debugging stop manifest.xml",
"validate": "office-addin-manifest validate manifest.xml",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "^3.36.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.1.0"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
"last 2 versions",
"ie 11"
]
}
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body></body>
</html>
@@ -0,0 +1,80 @@
/* global Office */
const { loadSession, buildMeetingMessage, BASE_URL } = require("../common");
Office.onReady(() => {});
function generateMeetingLinkFromCalendar(event) {
const session = loadSession();
if (!session?.access_token) {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: "Vous n'êtes pas connecté. Ouvrez les paramètres pour vous connecter.",
});
event.completed();
return;
}
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + session.access_token,
},
})
.then((res) => res.json())
.then((data) => {
console.log("Room created:", data);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur de lecture: ${getResult.error.message}`,
});
event.completed();
return;
}
item.body.setAsync(getResult.value + message, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur d'insertion: ${setResult.error.message}`,
});
event.completed();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message: "Lien de réunion inséré !",
icon: "Icon.80x80",
persistent: false,
});
} else {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur de localisation: ${locationResult.error.message}`,
});
}
event.completed();
});
});
});
})
.catch((err) => {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur: ${err.message}`,
});
event.completed();
});
}
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLinkFromCalendar);
+99
View File
@@ -0,0 +1,99 @@
/* global Office */
const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable
// ─── Session Storage ──────────────────────────────────────────────────────
function saveSession(data) {
const expiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000).toISOString()
: null;
const payload = JSON.stringify({
...data,
expiresAt,
savedAt: new Date().toISOString(),
});
localStorage.setItem("meetSession", payload);
const rs = Office.context.roamingSettings;
rs.set("meetSession", payload);
rs.saveAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("RoamingSettings save failed:", result.error.message);
}
});
}
function loadSession() {
let session = null;
try {
const stored = Office.context.roamingSettings.get("meetSession");
if (stored) session = JSON.parse(stored);
} catch (e) {
console.warn("RoamingSettings read failed:", e);
}
if (!session) {
try {
const stored = localStorage.getItem("meetSession");
if (stored) session = JSON.parse(stored);
} catch (e) {
console.warn("localStorage read failed:", e);
}
}
if (!session) return null;
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
console.warn("Token expired, clearing session.");
clearSession();
return null;
}
return session;
}
function clearSession() {
localStorage.removeItem("meetSession");
try {
const rs = Office.context.roamingSettings;
rs.remove("meetSession");
rs.saveAsync(() => console.log("RoamingSettings cleared."));
} catch (e) {
console.warn("Could not clear RoamingSettings:", e);
}
}
// ─── Meeting Message Builder ───────────────────────────────────────────────
function buildMeetingMessage(data) {
const url = data.url;
const phone = data.telephony?.phone_number;
const pin = data.telephony?.pin_code;
const formattedPin = pin
? pin.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#"
: "";
const formattedPhone = phone
? phone.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5")
: phone;
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion LaSuite Meet
<a href="${url}">${url}</a>
Ou appelez (audio uniquement)
(FR) ${formattedPhone}
Code : ${formattedPin}
────────────────────────────────────────</pre>`;
return { url, message };
}
module.exports = { BASE_URL, saveSession, loadSession, clearSession, buildMeetingMessage };
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Visio</title>
<link rel="stylesheet" href="taskpane.css" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text">Chargement...</p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span>Ajoutez facilement un lien de réunion Visio à vos événements Outlook.</span>
<a href="https://meet.numerique.gouv.fr" target="_blank" class="learn-more">En savoir plus</a>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button>
<p>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<button id="btn-generate">Ajouter une réunion Visio</button>
<button id="btn-disconnect">Se déconnecter</button>
</div>
</div>
<p id="status"></p>
</div>
</body>
</html>
@@ -0,0 +1,223 @@
const { BASE_URL, loadSession, saveSession, clearSession, buildMeetingMessage } = require("../common");
// ─── Views ────────────────────────────────────────────────────────────────
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
}
function setStatus(msg) {
document.getElementById("status").textContent = msg;
}
// ─── Polling ──────────────────────────────────────────────────────────────
function startPolling(session_id, { onSuccess, onTimeout, onError }) {
let pollCount = 0;
const pollInterval = setInterval(() => {
// ─── Timeout after 3 minutes ──────────────────────────────
if (pollCount++ > 180) {
clearInterval(pollInterval);
onTimeout?.();
return;
}
fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id }),
})
.then((res) => res.json())
.then((sessionData) => {
console.log("Polling:", sessionData);
if (sessionData.state === "authenticated" && sessionData.access_token) {
clearInterval(pollInterval);
onSuccess?.(sessionData);
}
})
.catch((err) => {
clearInterval(pollInterval);
onError?.(err);
});
}, 1000);
return pollInterval;
}
// ─── Transit Dialog ───────────────────────────────────────────────────────
function openTransitDialog(transit_token, { onCancel, onError }) {
const meetUrl = `${BASE_URL}/addons/transit/?transit_token=${transit_token}`;
Office.context.ui.displayDialogAsync(
meetUrl,
{ height: 60, width: 50, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
onError?.(asyncResult.error);
return;
}
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
onCancel?.();
dialog.close();
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
if (arg.error === 12006) {
setStatus("Dialog fermé. En attente d'authentification...");
}
});
return dialog;
}
);
}
// ─── Auth Flow ────────────────────────────────────────────────────────────
function connect() {
setStatus("Démarrage de la session...");
fetch(`${BASE_URL}/api/v1.0/addons/sessions/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((res) => res.json())
.then((data) => {
const session_id = data.session_id;
const transit_token = data.transit_token;
setStatus("En attente d'authentification...");
const pollInterval = startPolling(session_id, {
onSuccess: (sessionData) => {
saveSession(sessionData);
setStatus("Connecté !");
showView("auth");
},
onTimeout: () => {
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
showView("unauth");
},
onError: (err) => {
setStatus(`Erreur de polling: ${err.message}`);
},
});
openTransitDialog(transit_token, {
onCancel: () => clearInterval(pollInterval),
onError: (err) => {
clearInterval(pollInterval);
setStatus(`Erreur dialog: ${err.message}`);
},
});
})
.catch((err) => {
setStatus(`Erreur de connexion: ${err.message}`);
});
}
function disconnect() {
clearSession();
setStatus("Déconnecté.");
showView("unauth");
}
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
setStatus("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + session.access_token,
},
})
.then((res) => res.json())
.then((data) => {
console.log("Room created:", data);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
setStatus(`Erreur de lecture: ${getResult.error.message}`);
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
return;
}
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
setStatus(`Erreur d'insertion: ${setResult.error.message}`);
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, (locationResult) => {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
setStatus("Lien de réunion inséré !");
} else {
setStatus(`Erreur de localisation: ${locationResult.error.message}`);
}
});
} else {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
setStatus("Lien de réunion inséré !");
}
}
);
});
})
.catch((err) => {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
setStatus(`Erreur: ${err.message}`);
});
}
// ─── Init ─────────────────────────────────────────────────────────────────
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
setStatus("Connecté.");
showView("auth");
} else {
showView("unauth");
}
}
});
+110
View File
@@ -0,0 +1,110 @@
/* eslint-disable no-undef */
const devCerts = require("office-addin-dev-certs");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
const urlDev = "https://localhost:3000/";
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
}
module.exports = async (env, options) => {
const dev = options.mode === "development";
const config = {
devtool: "source-map",
entry: {
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
commands: "./src/commands/commands.js",
},
output: {
clean: true,
},
resolve: {
extensions: [".html", ".js"],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.html$/,
exclude: /node_modules/,
use: "html-loader",
},
{
test: /\.(png|jpg|jpeg|gif|ico)$/,
type: "asset/resource",
generator: {
filename: "assets/[name][ext][query]",
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: "taskpane.html",
template: "./src/taskpane/taskpane.html",
chunks: ["polyfill", "taskpane"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new CopyWebpackPlugin({
patterns: [
{
from: "assets/*",
to: "assets/[name][ext][query]",
},
{
from: "manifest*.xml",
to: "[name]" + "[ext]",
transform(content) {
if (dev) {
return content;
} else {
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
}
},
},
],
}),
new HtmlWebpackPlugin({
filename: "commands.html",
template: "./src/commands/commands.html",
chunks: ["polyfill", "commands"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new htmlWebpackInjectAttributesPlugin(),
],
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
},
server: {
type: "https",
options:
env.WEBPACK_BUILD || options.https !== undefined
? options.https
: await getHttpsOptions(),
},
port: process.env.npm_package_config_dev_server_port || 3000,
},
};
return config;
};
+2
View File
@@ -4,6 +4,8 @@ FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libglib2.0-0 \ libglib2.0-0 \
libgobject-2.0-0 \ libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
FROM base AS builder FROM base AS builder
+6 -6
View File
@@ -1,20 +1,20 @@
[project] [project]
name = "agents" name = "agents"
version = "1.12.0" version = "1.8.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.4.5", "livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.4.5", "livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.4.5", "livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6", "livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2", "python-dotenv==1.2.1",
"protobuf==6.33.5" "protobuf==6.33.5"
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff==0.15.6", "ruff==0.14.4",
] ]
[build-system] [build-system]
+1
View File
@@ -0,0 +1 @@
"""Meet core add-ons module."""
+192
View File
@@ -0,0 +1,192 @@
"""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 import JwtTokenService
logger = getLogger(__name__)
class SessionOwnershipError(Exception):
"""Raised when the claimed session_id does not match the result_token binding."""
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 = JwtTokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE, # todo - precise
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _session_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def _token_cache_key(self, result_token: str) -> str:
"""Wip."""
return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}"
def init_session(self) -> tuple[str, str, str]:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
result_token = secrets.token_urlsafe(32) # separate, never in any UR
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
# Store the session itself
cache.set(
self._session_cache_key(session_id),
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
# Store the token → session_id binding (same TTL)
cache.set(
self._token_cache_key(result_token),
session_id,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
# Transit token → session_id, very short TTL, one-time use
transit_token = secrets.token_urlsafe(32)
cache.set(
f"addon_transit_{transit_token}",
session_id,
timeout=120
)
print('$$ init transit_token')
print(transit_token)
return session_id, result_token, transit_token
# todo - wip
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
return self._get_and_maybe_clear(session_id)
def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict:
"""Resolve result_token → session_id → session data.
Verifies that the claimed_session_id matches the token binding,
proving the caller initiated this session (ownership check).
Clears the session once authenticated (one-time read).
"""
session_id = cache.get(self._token_cache_key(result_token))
if not session_id:
return {}
print("$$$ session_id")
print(session_id)
print("$$$ claimed_session_id")
print(claimed_session_id)
if not secrets.compare_digest(session_id, claimed_session_id):
raise SessionOwnershipError("Session ID does not match token binding.")
return self._get_and_maybe_clear(session_id)
def _get_and_maybe_clear(self, session_id: str) -> dict:
"""Wip."""
cache_key = self._session_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
# One-time read: clear both the session and the token binding
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, result_token: str | None = None) -> None:
"""Wip."""
cache.delete(self._session_cache_key(session_id))
if result_token:
cache.delete(self._token_cache_key(result_token))
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._session_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_jwt(user, settings.ADDONS_SCOPES)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
def token_to_session(self, result_token):
"""wip."""
return None
def consume_transit_token(self, transit_token: str) -> str | None:
"""Resolve and immediately delete the transit token (one-time use)."""
key = f"addon_transit_{transit_token}"
session_id = cache.get(key)
if session_id:
cache.delete(key) # consumed — cannot be replayed
return session_id
+62
View File
@@ -0,0 +1,62 @@
"""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."""
transit_token = request.GET.get("transit_token")
if not transit_token:
return render_error(request, _("Transit token is required."), status=400)
session_id = TokenExchangeService().consume_transit_token(transit_token)
if not session_id:
return render_error(request, _("Invalid or expired transit token."), status=404)
# Validate the session is still pending
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 = f"{settings.APPLICATION_BASE_URL}/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")
+113
View File
@@ -0,0 +1,113 @@
"""Add-ons API endpoints"""
from logging import getLogger
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from rest_framework import (
response as drf_response,
)
from rest_framework import decorators
from rest_framework import status as drf_status
from rest_framework import viewsets
from core.addons.service import TokenExchangeService, SessionOwnershipError
logger = getLogger(__name__)
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = []
def create(self, request):
"""Create a pending session.
Returns session_id in the body (client forwards it to the 3rd-party view).
Sets result_token as an HttpOnly cookie (the only poll credential).
"""
session_id, result_token, transit_token = TokenExchangeService().init_session()
response = drf_response.Response(
{"session_id": session_id, "transit_token": transit_token}, status=drf_status.HTTP_201_CREATED
)
response.set_cookie(
key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME,
value=result_token,
max_age=6000,
httponly=True,
secure=True,
samesite="None",
)
return response
@decorators.action(
detail=False,
methods=["post"],
url_name="wip",
url_path="wip",
permission_classes=[],
authentication_classes=[],
)
def long_poll(self, request):
"""Long-poll endpoint — only the cookie is accepted, never a session_id.
pk is intentionally ignored; the session is resolved from the cookie.
"""
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
session_id = request.data.get("session_id")
if not result_token:
return drf_response.Response(
{"detail": "Missing result token."},
status=drf_status.HTTP_401_UNAUTHORIZED,
)
if not session_id:
return drf_response.Response(
{"detail": "Missing result session id."},
status=drf_status.HTTP_401_UNAUTHORIZED,
)
try:
data = TokenExchangeService().get_session_by_token(
result_token=result_token,
claimed_session_id=session_id,
)
except SessionOwnershipError as e:
raise SuspiciousOperation(str(e)) from e
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
if data.get("state") == "pending":
return drf_response.Response(
{"state": "pending"},
status=drf_status.HTTP_202_ACCEPTED,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Explicit session teardown, resolves via cookie, not pk."""
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
if not result_token:
return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
# We need the session_id to clear both keys — resolve it first
session_id = TokenExchangeService().token_to_session(result_token)
if session_id:
TokenExchangeService().clear_session(session_id, result_token)
response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
return response
+2 -35
View File
@@ -197,38 +197,6 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
) )
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
def mark_as_failed_to_stop(modeladmin, request, queryset):
"""Force selected recordings status to failed_to_stop."""
eligible_statuses = [
models.RecordingStatusChoices.ACTIVE,
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.STOPPED,
]
eligible = queryset.filter(status__in=eligible_statuses)
skipped = queryset.exclude(status__in=eligible_statuses).count()
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
if updated > 0:
modeladmin.message_user(
request,
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
% {"count": updated},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s recording(s) with an ineligible status.")
% {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording) @admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin): class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration.""" """Recording admin interface declaration."""
@@ -256,7 +224,7 @@ class RecordingAdmin(admin.ModelAdmin):
"updated_at", "updated_at",
"worker_id", "worker_id",
) )
actions = [resend_notification, mark_as_failed_to_stop] actions = [resend_notification]
def get_queryset(self, request): def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries.""" """Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -308,7 +276,7 @@ class ApplicationAdmin(admin.ModelAdmin):
form = ApplicationAdminForm form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active") list_display = ("id", "name", "client_id", "get_scopes_display")
fields = [ fields = [
"name", "name",
"id", "id",
@@ -317,7 +285,6 @@ class ApplicationAdmin(admin.ModelAdmin):
"scopes", "scopes",
"client_id", "client_id",
"client_secret", "client_secret",
"is_active",
] ]
readonly_fields = ["id", "created_at", "updated_at"] readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline] inlines = [ApplicationDomainInline]
-21
View File
@@ -43,21 +43,6 @@ def get_frontend_configuration(request):
"expiration_days": settings.RECORDING_EXPIRATION_DAYS, "expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION, "max_duration": settings.RECORDING_MAX_DURATION,
}, },
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_count_by_user"
],
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_size"
],
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_extensions"
],
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_mimetypes"
],
},
"telephony": { "telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED, "enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
@@ -73,11 +58,5 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES, "default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
}, },
} }
if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
frontend_configuration["encryption"] = {
"enabled": True,
"vault_url": settings.ENCRYPTION_VAULT_URL,
"interface_url": settings.ENCRYPTION_INTERFACE_URL,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION) frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration) return Response(frontend_configuration)
-1
View File
@@ -13,7 +13,6 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE", "recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE", "storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED", "subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
} }
@classmethod @classmethod
-67
View File
@@ -1,67 +0,0 @@
"""API filters for meet' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from django_filters import BooleanFilter
from core import models
class FileFilter(django_filters.FilterSet):
"""
Custom filter for filtering files.
"""
class Meta:
model = models.File
fields = ["type"]
class ListFileFilter(FileFilter):
"""Filter class dedicated to the file viewset list method."""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted")
class Meta:
model = models.File
fields = ["is_creator_me", "type", "upload_state", "is_deleted"]
def filter_is_deleted(self, queryset, name, value):
"""
Filter files based on whether they are deleted or not.
Example:
- /api/v1.0/files/?is_deleted=false
→ Filters files that were not deleted
"""
if value is None:
return queryset
lookup = "__".join([name, "isnull"])
return queryset.filter(**{lookup: not value})
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
"""
Filter files based on the `creator` being the current user.
Example:
- /api/v1.0/files/?is_creator_me=true
→ Filters files created by the logged-in user
- /api/v1.0/files/?is_creator_me=false
→ Filters files created by other users
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(creator=user)
return queryset.exclude(creator=user)
-30
View File
@@ -1,8 +1,5 @@
"""Permission handlers for the Meet core app.""" """Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from ..models import RoleChoices from ..models import RoleChoices
@@ -109,30 +106,3 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
if not request.auth or not hasattr(request.auth, "video"): if not request.auth or not hasattr(request.auth, "video"):
return False return False
return request.auth.video.room == str(obj.id) return request.auth.video.room == str(obj.id)
class FilePermission(IsAuthenticated):
"""
Permissions applying to the file API endpoint.
Handling soft deletions specificities
"""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
if not settings.FILE_UPLOAD_ENABLED:
raise Http404
return super().has_permission(request, view)
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted files or if the user is not the owner
"""
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
raise Http404
if obj.creator != request.user:
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
+16 -288
View File
@@ -1,27 +1,16 @@
"""Client serializers for the Meet core app.""" """Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module # pylint: disable=abstract-method,no-name-in-module
import logging
from os.path import splitext
from typing import Literal
from urllib.parse import quote
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField from livekit.api import ParticipantPermission
from pydantic import BaseModel, Field
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils from core import models, utils
logger = logging.getLogger(__name__)
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
"""Serialize users.""" """Serialize users."""
@@ -30,17 +19,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"] fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "sub", "email", "full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class ResourceAccessSerializerMixin: class ResourceAccessSerializerMixin:
@@ -74,23 +54,6 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied( raise PermissionDenied(
"Only owners of a room can assign other users as owners." "Only owners of a room can assign other users as owners."
) )
# In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
# so the new member can decrypt the room's streams. Without it, they'd have
# access but no key — which is useless and confusing.
# Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
if not self.instance and "resource" in data:
resource = data["resource"]
if (
hasattr(resource, 'encryption_mode')
and resource.encryption_mode == models.EncryptionMode.ADVANCED
and not data.get("encrypted_symmetric_key")
):
raise serializers.ValidationError(
"Adding members to advanced encrypted rooms requires "
"an encrypted_symmetric_key for the new user."
)
return data return data
def validate_resource(self, resource): def validate_resource(self, resource):
@@ -115,7 +78,7 @@ class ResourceAccessSerializer(
class Meta: class Meta:
model = models.ResourceAccess model = models.ResourceAccess
fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"] fields = ["id", "user", "resource", "role"]
read_only_fields = ["id"] read_only_fields = ["id"]
def update(self, instance, validated_data): def update(self, instance, validated_data):
@@ -145,27 +108,9 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Room model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"] fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"] read_only_fields = ["id", "slug", "pin_code"]
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — prevent downgrading access level."""
instance = self.instance
if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED:
raise serializers.ValidationError(
"Encrypted rooms require restricted access level to enforce lobby approval."
)
return value
def validate_encryption_mode(self, value):
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
instance = self.instance
if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
raise serializers.ValidationError(
"Encryption cannot be disabled once enabled on a room."
)
return value
def to_representation(self, instance): def to_representation(self, instance):
""" """
Add users only for administrator users. Add users only for administrator users.
@@ -207,34 +152,18 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room: if should_access_room:
room_id = f"{instance.id!s}" room_id = f"{instance.id!s}"
username = request.query_params.get("username", None) username = request.query_params.get("username", None)
# In encrypted rooms, authenticated users must use their real name from
# the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
if instance.encryption_enabled and request.user.is_authenticated:
username = request.user.full_name or request.user.email
output["livekit"] = utils.generate_livekit_config( output["livekit"] = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
username=username, username=username,
configuration=configuration, configuration=configuration,
is_admin_or_owner=is_admin_or_owner, is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
) )
else: else:
del output["pin_code"] del output["pin_code"]
output["is_administrable"] = is_admin_or_owner output["is_administrable"] = is_admin_or_owner
# Include the current user's encrypted symmetric key for advanced E2EE
if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
try:
access = instance.accesses.get(user=request.user)
if access.encrypted_symmetric_key:
output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
except models.ResourceAccess.DoesNotExist:
pass
return output return output
@@ -272,27 +201,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only") raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class RecordingOptions(BaseModel):
"""Configuration options for recording.
Attributes:
language: ISO 639-1 language code compatible with whisperX.
When `None`, the transcription engine will attempt to
auto-detect the spoken language.
transcribe: Whether to transcribe the recorded audio.
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
"""
language: str | None = None
transcribe: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
class StartRecordingSerializer(BaseValidationOnlySerializer): class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests.""" """Validate start recording requests."""
@@ -305,19 +213,17 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.", "screen_recording or transcript.",
}, },
) )
options = SchemaField( options = serializers.JSONField(
schema=RecordingOptions | None,
required=False, required=False,
allow_null=True, allow_null=True,
help_text="Recording options", default=dict,
) )
class RequestEntrySerializer(BaseValidationOnlySerializer): class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data.""" """Validate request entry data."""
username = serializers.CharField(required=True, allow_blank=True) username = serializers.CharField(required=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -325,9 +231,6 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True) participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True) allow_entry = serializers.BooleanField(required=True)
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer): class CreationCallbackSerializer(BaseValidationOnlySerializer):
@@ -358,29 +261,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
) )
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
Control what a participant is allowed to publish, subscribe, and do within a room.
Unknown fields are rejected.
"""
can_subscribe: bool | None = None
can_publish: bool | None = None
can_publish_data: bool | None = None
can_publish_sources: list[TrackSource] = Field(default_factory=list)
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
agent: bool | None = None
can_subscribe_metrics: bool | None = None
model_config = {"extra": "forbid"}
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer): class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data.""" """Validate participant update data."""
@@ -392,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True, allow_null=True,
help_text="Participant attributes as JSON object", help_text="Participant attributes as JSON object",
) )
permission = SchemaField( permission = serializers.DictField(
schema=ParticipantPermission | None,
required=False, required=False,
allow_null=True, allow_null=True,
help_text="Participant permissions", help_text="Participant permission as JSON object",
) )
name = serializers.CharField( name = serializers.CharField(
max_length=255, max_length=255,
@@ -406,25 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant", help_text="Display name for the participant",
) )
def validate_permission(self, permission):
"""Validate that the given permission does not include forbidden or unimplemented fields."""
if permission is None:
return None
suspicious_fields = [
field
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
if getattr(permission, field) is not None
]
if suspicious_fields:
raise SuspiciousOperation(
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
return permission
def validate(self, attrs): def validate(self, attrs):
"""Ensure at least one update field is provided.""" """Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"] update_fields = ["metadata", "attributes", "permission", "name"]
@@ -440,144 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}." f"{', '.join(update_fields)}."
) )
return attrs if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
class ListFileSerializer(serializers.ModelSerializer): except ValueError as e:
"""Serialize File model for the API."""
url = serializers.SerializerMethodField(read_only=True)
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.File
fields = [
"id",
"created_at",
"updated_at",
"title",
"type",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
"size",
"description",
"url",
"abilities",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
"size",
"url",
"abilities",
]
def get_url(self, obj):
"""Return the URL of the file."""
if obj.is_pending_upload:
return None
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
def get_abilities(self, file) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if not request:
return {}
return file.get_abilities(request.user)
class FileSerializer(ListFileSerializer):
"""Default serializer File model for the API."""
def create(self, validated_data):
raise NotImplementedError("Create method can not be used.")
class CreateFileSerializer(ListFileSerializer):
"""Serializer used to create a new file"""
title = serializers.CharField(max_length=255, required=False)
policy = serializers.SerializerMethodField()
class Meta:
model = models.File
fields = [*ListFileSerializer.Meta.fields, "policy"]
read_only_fields = [
*(
field
for field in ListFileSerializer.Meta.read_only_fields
if field != "filename"
),
"policy",
]
def get_fields(self):
"""Force the id field to be writable."""
fields = super().get_fields()
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new file."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and models.File.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A file with this ID already exists. You cannot override it.",
code="file_create_existing_id",
)
return value
def validate(self, attrs):
"""Validate extension and fill title."""
# we run the default validation first to make sure the base data in attrs is ok
attrs = super().validate(attrs)
filename_root, ext = splitext(attrs["filename"])
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]]
if ext.lower() not in config_for_file_type["allowed_extensions"]:
logger.info(
"create_item: file extension not allowed %s for filename %s",
ext,
attrs["filename"],
)
raise serializers.ValidationError( raise serializers.ValidationError(
{"filename": _("This file extension is not allowed.")}, {"permission": f"Invalid permission: {str(e)}"}
code="item_create_file_extension_not_allowed", ) from e
)
# The title will be the filename if not provided
if not attrs.get("title", None):
attrs["title"] = filename_root
return attrs return attrs
def get_policy(self, file):
"""Return the policy to use if the item is a file."""
if file.upload_state == models.FileUploadStateChoices.READY:
return None
return utils.generate_upload_policy(file)
def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.")
+1 -50
View File
@@ -1,9 +1,7 @@
"""Throttling modules for the API.""" """Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle from rest_framework.throttling import AnonRateThrottle
from sentry_sdk import capture_message from sentry_sdk import capture_message
@@ -16,58 +14,11 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle.""" """Throttle for the monitored scoped rate throttle."""
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the authenticated user ID as the throttle cache key."""
if request.user and not request.user.is_authenticated:
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
return super().get_cache_key(request, view)
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle): class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry""" """Throttle Anonymous user requesting room entry"""
scope = "request_entry" scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling — the cookie will be set on the first
response, and throttling will apply from the second request onward.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
solely to guard against accidental hammering from buggy clients.
"""
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
if participant_id is None:
return None # No throttling for cookieless requests
return self.cache_format % {
"scope": self.scope,
"ident": participant_id,
}
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle): class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback""" """Throttle Anonymous user requesting room generation callback"""
+22 -428
View File
@@ -1,26 +1,16 @@
"""API endpoints""" """API endpoints"""
# pylint: disable=too-many-lines
import uuid import uuid
from logging import getLogger from logging import getLogger
from urllib.parse import unquote, urlparse from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage
from django.db.models import Q from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as django_filters from rest_framework import decorators, mixins, pagination, viewsets
from rest_framework import (
decorators,
filters,
mixins,
pagination,
viewsets,
)
from rest_framework import ( from rest_framework import (
exceptions as drf_exceptions, exceptions as drf_exceptions,
) )
@@ -32,13 +22,10 @@ from rest_framework import (
) )
from core import enums, models, utils from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import ( from core.recording.event.exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError, InvalidFileTypeError,
ParsingEventDataError, ParsingEventDataError,
) )
@@ -69,7 +56,6 @@ from core.services.participants_management import (
) )
from core.services.room_creation import RoomCreation from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers, throttling from . import permissions, serializers, throttling
@@ -91,20 +77,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
lookup_fields: list[str] = ["pk"] lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = [] lookup_url_kwargs: list[str] = []
def __getattribute__(self, file): def __getattribute__(self, item):
""" """
This method is overridden to allow to get the last lookup field or lookup url kwarg This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`. to keep compatibility with all methods used by the parent class `GenericViewSet`.
""" """
if file in ["lookup_field", "lookup_url_kwarg"]: if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1] return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(file) return super().__getattribute__(item)
def get_queryset(self): def get_queryset(self):
""" """
Get the list of files for this view. Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup. `lookup_fields` attribute is enumerated here to perform the nested lookup.
""" """
@@ -215,7 +201,6 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms. API endpoints to access and perform actions on rooms.
""" """
pagination_class = Pagination
permission_classes = [permissions.RoomPermissions] permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all() queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer serializer_class = serializers.RoomSerializer
@@ -281,32 +266,11 @@ class RoomViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.""" """Set the current user as owner of the newly created room."""
encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE)
# Block encrypted room creation if encryption is not enabled on this instance
if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
# Advanced encryption requires the vault service to be configured
if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
)
# Encrypted rooms must use restricted access to enforce lobby approval
# before the encryption key is shared with participants.
if encryption_mode != models.EncryptionMode.NONE:
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save() room = serializer.save()
encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
models.ResourceAccess.objects.create( models.ResourceAccess.objects.create(
resource=room, resource=room,
user=self.request.user, user=self.request.user,
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
encrypted_symmetric_key=encrypted_symmetric_key,
) )
if callback_id := self.request.data.get("callback_id"): if callback_id := self.request.data.get("callback_id"):
@@ -332,20 +296,12 @@ class RoomViewSet(
) )
mode = serializer.validated_data["mode"] mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options") options = serializer.validated_data["options"]
room = self.get_object() room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"detail": "Recording is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# May raise exception if an active or initiated recording already exist for the room # May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create( recording = models.Recording.objects.create(
room=room, room=room, mode=mode, options=options
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
) )
models.RecordingAccess.objects.create( models.RecordingAccess.objects.create(
@@ -411,10 +367,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="request-entry", url_path="request-entry",
permission_classes=[], permission_classes=[],
throttle_classes=[ throttle_classes=[throttling.RequestEntryAnonRateThrottle],
throttling.RequestEntryAuthenticatedUserRateThrottle,
throttling.RequestEntryAnonRateThrottle,
],
) )
def request_entry(self, request, pk=None): # pylint: disable=unused-argument def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room""" """Request entry to a room"""
@@ -423,28 +376,12 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
room = self.get_object() room = self.get_object()
validated_data = serializer.validated_data
# Advanced encrypted rooms require authentication
if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
return drf_response.Response(
{"detail": "This meeting requires authentication to join."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# In encrypted rooms, authenticated users must use their real name
# from the OIDC profile — they cannot choose an arbitrary name.
if room.encryption_enabled and request.user.is_authenticated:
validated_data["username"] = (
request.user.full_name or request.user.email
)
lobby_service = LobbyService() lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry( participant, livekit = lobby_service.request_entry(
room=room, room=room,
request=request, request=request,
**validated_data, **serializer.validated_data,
) )
response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id) lobby_service.prepare_response(response, participant.id)
@@ -480,9 +417,6 @@ class RoomViewSet(
room_id=room.id, room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")), participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"), allow_entry=serializer.validated_data.get("allow_entry"),
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
) )
return drf_response.Response({"message": "Participant was updated."}) return drf_response.Response({"message": "Participant was updated."})
@@ -510,14 +444,6 @@ class RoomViewSet(
lobby_service = LobbyService() lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id) participants = lobby_service.list_waiting_participants(room.id)
# Only expose email and ephemeral keys in encrypted rooms.
# Strip them otherwise to avoid leaking personal data.
if not room.encryption_enabled:
for p in participants:
p.pop("email", None)
p.pop("ephemeral_public_key", None)
return drf_response.Response({"participants": participants}) return drf_response.Response({"participants": participants})
@decorators.action( @decorators.action(
@@ -542,7 +468,9 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR: if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e raise e
return drf_response.Response({"status": "error"}, status=status_code) return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action( @decorators.action(
detail=False, detail=False,
@@ -620,12 +548,6 @@ class RoomViewSet(
room = self.get_object() room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"error": "Transcription is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try: try:
SubtitleService().start_subtitle(room) SubtitleService().start_subtitle(room)
except SubtitleException: except SubtitleException:
@@ -685,15 +607,13 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data) serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try: try:
ParticipantsManagement().update( ParticipantsManagement().update(
room_name=str(room.pk), room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]), identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"), metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"), attributes=serializer.validated_data.get("attributes"),
permission=permission.model_dump() if permission else None, permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"), name=serializer.validated_data.get("name"),
) )
except ParticipantsManagementException: except ParticipantsManagementException:
@@ -815,19 +735,14 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data) recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e: except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
except InvalidBucketError as e: except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
except InvalidFilepathError: except InvalidFileTypeError as e:
return drf_response.Response( return drf_response.Response(
{"message": "Notification ignored."}, {"message": f"Ignore this file type, {e}"},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
) )
try: try:
@@ -874,7 +789,7 @@ class RecordingViewSet(
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL") original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url: if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url) logger.debug("Original url: '%s'", original_url)
@@ -891,7 +806,7 @@ class RecordingViewSet(
try: try:
return match.groupdict() return match.groupdict()
except (ValueError, AttributeError) as exc: except (ValueError, AttributeError) as exc:
logger.warning("Failed to extract parameters from subrequest URL: %s", exc) logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth") @decorators.action(detail=False, methods=["get"], url_path="media-auth")
@@ -915,7 +830,7 @@ class RecordingViewSet(
recording_id = url_params["recording_id"] recording_id = url_params["recording_id"]
extension = url_params["extension"] extension = url_params["extension"]
if extension not in [file.value for file in FileExtension]: if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."}) raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try: try:
@@ -939,324 +854,3 @@ class RecordingViewSet(
request = utils.generate_s3_authorization_headers(recording.key) request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200) return drf_response.Response("authorized", headers=request.headers, status=200)
# pylint: disable=too-many-public-methods
class FileViewSet(
SerializerPerActionMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
FileViewSet API.
This viewset provides CRUD operations and additional actions for managing files.
### API Endpoints:
1. **List**: Retrieve a paginated list of files.
Example: GET /files/?page=2
2. **Retrieve**: Get a specific file by its ID.
Example: GET /files/{id}/
3. **Create**: Create a new file.
Example: POST /files/
4. **Update**: Update a file by its ID.
Example: PUT /files/{id}/
5. **Delete**: Soft delete a file by its ID.
Example: DELETE /files/{id}/
### Ordering: created_at, updated_at, title
Example:
- Ascending: GET /api/v1.0/files/?ordering=created_at
### Filtering:
- `is_creator_me=true`: Returns files created by the current user.
- `is_creator_me=false`: Returns files created by other users.
- `is_deleted=false`: Returns files that are not (soft) deleted
Example:
- GET /api/v1.0/files/?is_creator_me=true
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
### Notes:
- Implements soft delete logic to retain file
"""
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.FilePermission,
]
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
default_serializer_class = serializers.FileSerializer
serializer_classes = {
"list": serializers.ListFileSerializer,
"create": serializers.CreateFileSerializer,
}
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
filterset_class = ListFileFilter
def get_queryset(self):
"""Get queryset that defaults to the current request user."""
user = self.request.user
queryset = super().get_queryset().select_related("creator")
if not user.is_authenticated:
return queryset.none()
# For now, we force the filtering on the current user in all cases, might evolve later
queryset = queryset.filter(creator=user)
return queryset
def get_response_for_queryset(self, queryset, context=None):
"""Return paginated response for the queryset if requested."""
context = context or self.get_serializer_context()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True, context=context)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True, context=context)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator of the newly created file."""
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
file_type = serializer.validated_data["type"]
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
count = models.File.objects.filter(
creator=self.request.user,
deleted_at__isnull=True,
type=file_type,
).count()
if count >= config_for_file_type["max_count_by_user"]:
logger.info(
"create_item: user reached max files per user for type %s",
file_type,
)
raise serializers.PermissionDenied(
_("You have reached the maximum number of files for this type.")
)
serializer.save(creator=self.request.user)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@FeatureFlag.require("file_upload")
def upload_ended(self, request, *args, **kwargs):
"""
Check the actual uploaded file and mark it as ready.
"""
file = self.get_object()
if not file.is_pending_upload:
raise drf_exceptions.ValidationError(
{"file": "This action is only available for files in PENDING state."},
code="file_upload_state_not_pending",
)
s3_client = default_storage.connection.meta.client
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
self._complete_file_deletion(file)
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
# Use improved MIME type detection combining magic bytes and file extension
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
self._complete_file_deletion(file)
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
# Not yet implemented
# Change the file.upload_state when this will be done
# malware_detection.analyse_file(file.file_key, file_id=file.id)
serializer = self.get_serializer(file)
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
def _complete_file_deletion(self, file):
"""Delete a file completely."""
file.soft_delete()
file.hard_delete()
process_file_deletion.delay(file.id)
def _authorize_subrequest(self, request, pattern):
"""
Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(unquote(parsed_url.path))
if not match:
logger.warning(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf_exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
raise drf_exceptions.PermissionDenied()
# Fetch the file and check if the user has access
queryset = models.File.objects.all()
# No suspicious analysis implemented yet
# queryset = self._filter_suspicious_files(queryset, request.user)
try:
file = queryset.get(pk=pk)
except models.File.DoesNotExist as exc:
logger.warning("File with ID '%s' does not exist", pk)
raise drf_exceptions.PermissionDenied() from exc
user_abilities = file.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.warning(
"User '%s' lacks permission for file '%s'", request.user.id, pk
)
raise drf_exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, request.user.id, file
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
@FeatureFlag.require("file_upload")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to an file's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
url_params, _, file = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
if file.is_pending_upload:
logger.warning("File '%s' is not ready", file.id)
raise drf_exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
+1 -7
View File
@@ -14,15 +14,9 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long # pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile( RECORDING_STORAGE_URL_PATTERN = re.compile(
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})" f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
) )
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for # Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in # the choice of languages which should not be limited to the few languages active in
# the app. # the app.
@@ -203,7 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Application not found: %s", client_id) logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.is_active: if not application.active:
logger.warning( logger.warning(
"Inactive application attempted authentication: %s", client_id "Inactive application attempted authentication: %s", client_id
) )
@@ -214,6 +214,25 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
raise exceptions.AuthenticationFailed("Invalid token type.") 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.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
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,
)
class ResourceServerBackend(LaSuiteBackend): class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval.""" """OIDC Resource Server backend for user creation and retrieval."""
+4 -3
View File
@@ -61,12 +61,12 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e: except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from 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): if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials") raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
email = serializer.validated_data["scope"] email = serializer.validated_data["scope"]
try: try:
validate_email(email) validate_email(email)
@@ -173,6 +173,7 @@ class RoomViewSet(
authentication_classes = [ authentication_classes = [
authentication.ApplicationJWTAuthentication, authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication, ResourceServerAuthentication,
] ]
permission_classes = [ permission_classes = [
+1 -43
View File
@@ -2,11 +2,8 @@
Core application factories Core application factories
""" """
from io import BytesIO
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.files.storage import default_storage
from django.utils.text import slugify from django.utils.text import slugify
import factory.fuzzy import factory.fuzzy
@@ -129,7 +126,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
model = models.Application model = models.Application
name = factory.Faker("company") name = factory.Faker("company")
is_active = True active = True
client_id = factory.LazyFunction(utils.generate_client_id) client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret) client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = [] scopes = []
@@ -156,42 +153,3 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
domain = factory.Faker("domain_name") domain = factory.Faker("domain_name")
application = factory.SubFactory(ApplicationFactory) application = factory.SubFactory(ApplicationFactory)
class FileFactory(factory.django.DjangoModelFactory):
"""A factory to create files"""
class Meta:
model = models.File
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"file{n}")
creator = factory.SubFactory(UserFactory)
deleted_at = None
type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices])
filename = factory.lazy_attribute(lambda o: fake.file_name())
upload_state = None
size = None
@factory.post_generation
def update_upload_state(self, create, extracted, **kwargs):
"""Change the upload state of a file."""
if create and extracted:
self.upload_state = extracted
self.save()
@factory.post_generation
def upload_bytes(self, create, extracted, **kwargs):
"""Save content of the file into the storage"""
if create and extracted is not None:
content = (
extracted
if isinstance(extracted, bytes)
else str(extracted).encode("utf-8")
)
self.filename = kwargs.get("filename", self.filename or "content.txt")
self.size = len(content)
self.save()
default_storage.save(self.file_key, BytesIO(content))
+2 -2
View File
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')), ('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')), ('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')), ('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')), ('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')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)), ('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')), ('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')), ('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='resource', model_name='resource',
name='users', name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='resourceaccess', model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39 # Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='user', model_name='user',
name='language', name='language',
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'), field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
), ),
] ]
@@ -1,5 +1,5 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52 # Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='user', model_name='user',
name='language', name='language',
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'), field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
), ),
] ]
-42
View File
@@ -1,42 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-03 15:22
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_recording_options'),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('type', models.CharField(choices=[('background_image', 'Background image')], max_length=25)),
('title', models.CharField(max_length=255, verbose_name='title')),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('hard_deleted_at', models.DateTimeField(blank=True, null=True)),
('filename', models.CharField(max_length=255)),
('upload_state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready')], max_length=25)),
('mimetype', models.CharField(blank=True, max_length=255, null=True)),
('size', models.BigIntegerField(blank=True, null=True)),
('description', models.TextField(blank=True, null=True)),
('malware_detection_info', models.JSONField(blank=True, default=dict, help_text='Malware detection info when the analysis status is unsafe.', null=True)),
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='files_created', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'File',
'verbose_name_plural': 'Files',
'db_table': 'file',
'ordering': ('created_at',),
'indexes': [models.Index(fields=['creator', 'type', '-created_at'], name='file_creator_730cce_idx')],
},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-11 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0017_file'),
]
operations = [
migrations.RenameField(
model_name='application',
old_name='active',
new_name='is_active',
),
]
@@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_enabled",
field=models.BooleanField(
default=False,
help_text="Whether end-to-end encryption is enabled for this room.",
verbose_name="Encryption enabled",
),
),
]
@@ -1,51 +0,0 @@
"""Replace encryption_enabled boolean with encryption_mode enum."""
from django.db import migrations, models
def migrate_encryption_enabled_to_mode(apps, schema_editor):
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
Room = apps.get_model("core", "Room")
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
def migrate_mode_to_encryption_enabled(apps, schema_editor):
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
Room = apps.get_model("core", "Room")
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("core", "0019_room_encryption_enabled"),
]
operations = [
# 1. Add the new encryption_mode field
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Basic encryption"),
("advanced", "Advanced encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
# 2. Migrate existing data
migrations.RunPython(
migrate_encryption_enabled_to_mode,
migrate_mode_to_encryption_enabled,
),
# 3. Remove the old boolean field
migrations.RemoveField(
model_name="room",
name="encryption_enabled",
),
]
@@ -1,23 +0,0 @@
"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0020_room_encryption_mode"),
]
operations = [
migrations.AddField(
model_name="resourceaccess",
name="encrypted_symmetric_key",
field=models.TextField(
blank=True,
default="",
help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
verbose_name="Encrypted symmetric key",
),
),
]
+2 -217
View File
@@ -1,14 +1,11 @@
""" """
Declare and configure the models for the Meet core application Declare and configure the models for the Meet core application
# pylint: disable=too-many-lines
""" """
# pylint: disable=too-many-lines
import secrets import secrets
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from logging import getLogger from logging import getLogger
from os.path import splitext
from typing import List, Optional from typing import List, Optional
from django.conf import settings from django.conf import settings
@@ -17,7 +14,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models, transaction from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.text import capfirst, slugify from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -98,14 +95,6 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access") RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode choices for rooms."""
NONE = "none", _("No encryption")
BASIC = "basic", _("Basic encryption")
ADVANCED = "advanced", _("Advanced encryption")
class BaseModel(models.Model): class BaseModel(models.Model):
""" """
Serves as an abstract base model for other models, ensuring that records are validated Serves as an abstract base model for other models, ensuring that records are validated
@@ -332,15 +321,6 @@ class ResourceAccess(BaseModel):
role = models.CharField( role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
) )
encrypted_symmetric_key = models.TextField(
blank=True,
default='',
verbose_name=_("Encrypted symmetric key"),
help_text=_(
"Vault-wrapped symmetric encryption key for advanced E2EE mode. "
"Each user's copy is encrypted for their own vault public key."
),
)
class Meta: class Meta:
db_table = "meet_resource_access" db_table = "meet_resource_access"
@@ -405,13 +385,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices, choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
) )
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
configuration = models.JSONField( configuration = models.JSONField(
blank=True, blank=True,
default=dict, default=dict,
@@ -466,11 +439,6 @@ class Room(Resource):
"""Check if a room is public""" """Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC return self.access_level == RoomAccessLevel.PUBLIC
@property
def encryption_enabled(self):
"""Check if any encryption mode is active."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod @staticmethod
def generate_unique_pin_code(length): def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code""" """Generate a unique n-digit PIN code"""
@@ -788,7 +756,7 @@ class Application(BaseModel):
verbose_name=_("Application name"), verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."), help_text=_("Descriptive name for this application."),
) )
is_active = models.BooleanField(default=True) active = models.BooleanField(default=True)
client_id = models.CharField( client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id max_length=100, unique=True, default=utils.generate_client_id
) )
@@ -861,186 +829,3 @@ class ApplicationDomain(BaseModel):
self.domain = self.domain.lower().strip() self.domain = self.domain.lower().strip()
super().save(*args, **kwargs) super().save(*args, **kwargs)
class FileUploadStateChoices(models.TextChoices):
"""Possible states of a file."""
PENDING = "pending", _("Pending")
# Commented out for now, as we may need this when we implement the malware detection logic.
# ANALYZING = "analyzing", _("Analyzing")
# SUSPICIOUS = "suspicious", _("Suspicious")
# FILE_TOO_LARGE_TO_ANALYZE = (
# "file_too_large_to_analyze",
# _("File too large to analyze"),
# )
READY = "ready", _("Ready")
class FileTypeChoices(models.TextChoices):
"""Defines the possible types of a file."""
BACKGROUND_IMAGE = "background_image", _("Background image")
class File(BaseModel):
"""File uploaded by a user."""
type = models.CharField(
max_length=25,
choices=FileTypeChoices.choices,
null=False,
blank=False,
)
title = models.CharField(_("title"), max_length=255)
creator = models.ForeignKey(
User,
on_delete=models.RESTRICT,
related_name="files_created",
blank=True,
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
hard_deleted_at = models.DateTimeField(null=True, blank=True)
filename = models.CharField(max_length=255, null=False, blank=False)
upload_state = models.CharField(
max_length=25,
choices=FileUploadStateChoices.choices,
)
mimetype = models.CharField(max_length=255, null=True, blank=True)
size = models.BigIntegerField(null=True, blank=True)
description = models.TextField(null=True, blank=True)
malware_detection_info = models.JSONField(
null=True,
blank=True,
default=dict,
help_text=_("Malware detection info when the analysis status is unsafe."),
)
class Meta:
db_table = "file"
verbose_name = _("File")
verbose_name_plural = _("Files")
ordering = ("created_at",)
indexes = [
models.Index(fields=["creator", "type", "-created_at"]),
]
def __str__(self):
return str(self.title)
def save(self, *args, **kwargs):
"""Set the upload state to pending if it's the first save and it's a file."""
if self.created_at is None:
self.upload_state = FileUploadStateChoices.PENDING
return super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False):
if self.deleted_at is None:
raise RuntimeError("The file must be soft deleted before being deleted.")
return super().delete(using, keep_parents)
@property
def is_pending_upload(self):
"""Return whether the file is in a pending upload state"""
return self.upload_state == FileUploadStateChoices.PENDING
@property
def extension(self):
"""Return the extension related to the filename."""
if self.filename is None:
raise RuntimeError(
"The file must have a filename to compute its extension."
)
_, extension = splitext(self.filename)
if extension:
return extension.lstrip(".")
return None
@property
def key_base(self):
"""Key base of the location where the file is stored in object storage."""
if not self.pk:
raise RuntimeError(
"The file instance must be saved before requesting a storage key."
)
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
@property
def file_key(self):
"""Key used to store the file in object storage."""
_, extension = splitext(self.filename)
# We store only the extension in the storage system to avoid
# leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the file.
"""
# Characteristics that are based only on specific access
is_creator = user == self.creator
retrieve = is_creator
is_deleted = self.deleted_at is not None
can_update = is_creator and not is_deleted and user.is_authenticated
can_hard_delete = is_creator and user.is_authenticated
can_destroy = can_hard_delete and not is_deleted
return {
"destroy": can_destroy,
"hard_delete": can_hard_delete,
"retrieve": retrieve,
"media_auth": retrieve and not is_deleted,
"partial_update": can_update,
"update": can_update,
"upload_ended": can_update and user.is_authenticated,
}
@transaction.atomic
def soft_delete(self):
"""
Soft delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at:
raise RuntimeError("This file is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def hard_delete(self):
"""
Hard delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.hard_deleted_at:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("This file is already hard deleted."),
code="file_hard_delete_already_effective",
)
}
)
if self.deleted_at is None:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("To hard delete a file, it must first be soft deleted."),
code="file_hard_delete_should_soft_delete_first",
)
}
)
self.hard_deleted_at = timezone.now()
self.save(update_fields=["hard_deleted_at"])
@@ -167,7 +167,6 @@ class NotificationService:
owner_access.user.timezone owner_access.user.timezone
).strftime("%H:%M"), ).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}", "download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
} }
headers = { headers = {
+1 -3
View File
@@ -9,8 +9,6 @@ from typing import Any, Dict, Optional, Protocol
from django.conf import settings from django.conf import settings
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import ( from .exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError, InvalidFilepathError,
@@ -88,7 +86,7 @@ class MinioParser:
# pylint: disable=line-too-long # pylint: disable=line-too-long
self._filepath_regex = re.compile( self._filepath_regex = re.compile(
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})" r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
) )
@staticmethod @staticmethod
@@ -16,7 +16,7 @@ class RecordingEventsError(Exception):
class RecordingEventsService: class RecordingEventsService:
"""Handles recording-related LiveKit webhook events.""" """Handles recording-related Livekit webhook events."""
@staticmethod @staticmethod
def handle_update(recording, egress_status): def handle_update(recording, egress_status):
+10 -13
View File
@@ -4,7 +4,7 @@ import smtplib
from logging import getLogger from logging import getLogger
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import get_language, override from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -45,18 +45,15 @@ class InvitationService:
) )
) # Force translation ) # Force translation
email = EmailMultiAlternatives(
subject=subject,
body=msg_plain,
from_email=settings.EMAIL_FROM,
to=[],
bcc=emails,
)
email.attach_alternative(msg_html, "text/html")
try: try:
email.send() send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
logger.error("invitations were not sent: %s", e) logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e raise InvitationError("Could not send invitation") from e
+7 -91
View File
@@ -46,36 +46,15 @@ class LobbyParticipant:
username: str username: str
color: str color: str
id: str id: str
is_authenticated: bool = False
email: Optional[str] = None
suite_user_id: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
encrypted_vault_key: str = ''
def to_dict(self) -> Dict[str, str]: def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation.""" """Serialize the participant object to a dict representation."""
result = { return {
"status": self.status.value, "status": self.status.value,
"username": self.username, "username": self.username,
"id": self.id, "id": self.id,
"color": self.color, "color": self.color,
"is_authenticated": self.is_authenticated,
} }
if self.email:
result["email"] = self.email
if self.suite_user_id:
result["suite_user_id"] = self.suite_user_id
if self.ephemeral_public_key:
result["ephemeral_public_key"] = self.ephemeral_public_key
if self.encrypted_key:
result["encrypted_key"] = self.encrypted_key
if self.admin_ephemeral_public_key:
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
if self.encrypted_vault_key:
result["encrypted_vault_key"] = self.encrypted_vault_key
return result
@classmethod @classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant": def from_dict(cls, data: dict) -> "LobbyParticipant":
@@ -89,13 +68,6 @@ class LobbyParticipant:
username=data["username"], username=data["username"],
id=data["id"], id=data["id"],
color=data["color"], color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
suite_user_id=data.get("suite_user_id"),
ephemeral_public_key=data.get("ephemeral_public_key", ''),
encrypted_key=data.get("encrypted_key", ''),
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=data.get("encrypted_vault_key", ''),
) )
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:") logger.exception("Error creating Participant from dict:")
@@ -127,7 +99,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME, key=settings.LOBBY_COOKIE_NAME,
value=participant_id, value=participant_id,
httponly=True, httponly=True,
secure=not settings.DEBUG, secure=True,
samesite="Lax", samesite="Lax",
) )
@@ -139,16 +111,11 @@ class LobbyService:
1. The room is public (open to everyone) 1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated 2. The room has TRUSTED access level and the user is authenticated
Encrypted rooms never bypass the lobby participants must go through
the lobby key exchange to receive the encryption key.
Note: Room access levels can change while participants are waiting in the lobby. Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting. participants who have already begun waiting.
""" """
if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
return False
return room.is_public or ( return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated and user.is_authenticated
@@ -159,11 +126,10 @@ class LobbyService:
room, room,
request, request,
username: str, username: str,
ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]: ) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant. """Request entry to a room for a participant.
The usual status transitions are: This usual status transitions is:
UNKNOWN -> WAITING -> (ACCEPTED | DENIED) UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
Flow: Flow:
@@ -198,42 +164,19 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if participant is None:
participant = self.enter( participant = self.enter(room.id, participant_id, username)
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# If the joiner comes back with a different ephemeral key (e.g. browser # wrongly named, contains access token to join a room
# closed and reopened), they can no longer decrypt the encrypted symmetric
# key. Reset them to WAITING so the admin re-accepts with the new key.
if (
ephemeral_public_key
and participant.ephemeral_public_key
and ephemeral_public_key != participant.ephemeral_public_key
):
participant = self.enter(
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
return participant, None
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
@@ -242,7 +185,6 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
@@ -259,11 +201,7 @@ class LobbyService:
) )
def enter( def enter(
self, room_id: UUID, participant_id: str, username: str, self, room_id: UUID, participant_id: str, username: str
is_authenticated: bool = False,
email: Optional[str] = None,
suite_user_id: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby. """Add participant to waiting lobby.
@@ -278,10 +216,6 @@ class LobbyService:
username=username, username=username,
id=participant_id, id=participant_id,
color=color, color=color,
is_authenticated=is_authenticated,
email=email,
suite_user_id=suite_user_id,
ephemeral_public_key=ephemeral_public_key,
) )
try: try:
@@ -350,9 +284,6 @@ class LobbyService:
room_id: UUID, room_id: UUID,
participant_id: str, participant_id: str,
allow_entry: bool, allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None: ) -> None:
"""Handle decision on participant entry. """Handle decision on participant entry.
@@ -371,13 +302,7 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT, "timeout": settings.LOBBY_DENIED_TIMEOUT,
} }
self._update_participant_status( self._update_participant_status(room_id, participant_id, **decision)
room_id, participant_id,
encrypted_key=encrypted_key,
admin_ephemeral_public_key=admin_ephemeral_public_key,
encrypted_vault_key=encrypted_vault_key,
**decision,
)
def _update_participant_status( def _update_participant_status(
self, self,
@@ -385,9 +310,6 @@ class LobbyService:
participant_id: str, participant_id: str,
status: LobbyParticipantStatus, status: LobbyParticipantStatus,
timeout: int, timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None: ) -> None:
"""Update participant status with appropriate timeout.""" """Update participant status with appropriate timeout."""
@@ -408,12 +330,6 @@ class LobbyService:
raise raise
participant.status = status participant.status = status
if encrypted_key:
participant.encrypted_key = encrypted_key
if admin_ephemeral_public_key:
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
if encrypted_vault_key:
participant.encrypted_vault_key = encrypted_vault_key
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
-49
View File
@@ -1,49 +0,0 @@
# ruff: noqa: PLC0415
from django.conf import settings
def task(*d_args, **d_kwargs):
"""
Decorator compatible with Celery's @app.task, but works without Celery.
If Celery is available, returns a real Celery task.
If not, returns the original function and provides `.delay()`/`.apply_async()`
as synchronous fallbacks (so existing call sites don't break).
Notes:
Mostly LLM-generated.
"""
def _fallback_wrap(func):
def delay(*args, **kwargs):
return func(*args, **kwargs)
def apply_async(args=None, kwargs=None, **_options):
return func(*(args or ()), **(kwargs or {}))
func.delay = delay
func.apply_async = apply_async
return func
# Handle bare decorator usage: @task
if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs:
func = d_args[0]
if settings.CELERY_ENABLED:
from meet.celery_app import app as _celery_app
return _celery_app.task(func)
return _fallback_wrap(func)
# Handle parameterized usage: @task(...), e.g. @task(bind=True)
def _decorate(func):
if settings.CELERY_ENABLED:
from meet.celery_app import app as _celery_app
return _celery_app.task(*d_args, **d_kwargs)(func)
return _fallback_wrap(func)
return _decorate
__all__ = ("task",)
-36
View File
@@ -1,36 +0,0 @@
"""
Tasks related to files.
"""
import logging
from django.core.files.storage import default_storage
from core.models import File
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def process_file_deletion(file_id):
"""
Process the deletion of a file.
Definitely delete it in the database.
Delete the files from the storage.
"""
logger.info("Processing item deletion for %s", file_id)
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
logger.error("Item %s does not exist", file_id)
return
if file.hard_deleted_at is None:
logger.error("To process an item deletion, it must be hard deleted first.")
return
logger.info("Deleting file %s", file.file_key)
default_storage.delete(file.file_key)
file.delete()
@@ -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>
+1 -1
View File
@@ -1,4 +1,4 @@
"""Custom template tags for the core application of Meet.""" """Custom template tags for the core application of People."""
import base64 import base64
@@ -1,385 +0,0 @@
"""
Tests for files API endpoint in meet's core app: create
"""
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
from django.utils import timezone
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
from core.models import File, FileTypeChoices, FileUploadStateChoices
pytestmark = pytest.mark.django_db
def test_api_files_create_anonymous():
"""Anonymous users should not be allowed to create items."""
response = APIClient().post(
"/api/v1.0/files/",
{
"title": "My file",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not File.objects.exists()
def test_api_files_create_authenticated_success():
"""
Authenticated users should be able to create files and should automatically be declared
as the owner of the newly created file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"title": "my file",
"filename": "my_file.png",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "my file"
assert file.creator == user
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
assert file.upload_state == FileUploadStateChoices.PENDING
def test_api_files_create_file_authenticated_no_filename():
"""
Creating a file item without providing a filename should fail.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"title": "my item",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This field is required."]}
def test_api_files_create_file_authenticated_success():
"""
Authenticated users should be able to create a file file and must provide a filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now()
with freeze_time(now):
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"title": "Eiffle tower",
"filename": "file.png",
},
format="json",
)
assert response.status_code == 201
file = File.objects.get()
assert file.title == "Eiffle tower"
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
assert file.filename == "file.png"
response_data = response.json()
assert response_data["creator"] is not None, response_data
assert response.json().get("policy") is not None
policy = response.json()["policy"]
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
assert query_params.pop("X-Amz-Credential") == [
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
]
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
assert query_params.pop("X-Amz-Expires") == ["60"]
assert query_params.pop("X-Amz-SignedHeaders") == ["host;x-amz-acl"]
assert query_params.pop("X-Amz-Signature") is not None
assert len(query_params) == 0
def test_api_files_create_file_authenticated_extension_not_allowed():
"""
Creating a file item with an extension not allowed should fail.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"title": "Paris tower",
"filename": "file.notallowed",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_authenticated_extension_case_insensitive():
"""
Creating a file item with an extension, no matter the case used, should be allowed.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "file"
def test_api_files_create_file_disabled(settings):
"""
Creating a file is denied if file upload is disabled
"""
settings.FILE_UPLOAD_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 404
assert not File.objects.exists()
def test_api_files_create_file_authenticated_not_checking_extension(settings):
"""
Creating a file with an extension not allowed should not fail when restrictions are disabled.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.notallowed",
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "file"
def test_api_files_create_file_authenticated_no_extension_but_checking_it_should_fail(
settings,
):
"""
Creating a file without an extension but checking the extension should fail.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_should_fail(
settings,
):
"""
Creating a hidden file (starting with a dot) but checking the extension should fail.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": ".file",
},
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_too_many(
settings,
):
"""
Creating a file is forbidden if above user limit.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_count_by_user": 1,
},
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "1.png",
},
)
assert response.status_code == 201
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "2.png",
},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You have reached the maximum number of files for this type."
}
assert File.objects.count() == 1
def test_api_files_create_force_id_success():
"""It should be possible to force the item ID when creating a item."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
forced_id = uuid4()
response = client.post(
"/api/v1.0/files/",
{
"id": str(forced_id),
"title": "my item",
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
assert response.status_code == 201, response.json()
files = File.objects.all()
assert len(files) == 1
assert files[0].id == forced_id
def test_api_files_create_force_id_existing():
"""
It should not be possible to use the ID of an existing file when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory()
response = client.post(
"/api/v1.0/files/",
{
"id": str(file.id),
"title": "my file",
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A file with this ID already exists. You cannot override it."]
}
@pytest.mark.django_db(transaction=True)
def test_api_files_create_file_race_condition():
"""
It should be possible to create several files at the same time
without causing any race conditions or data integrity issues.
"""
def create_item(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_item, "my item 1")
future2 = executor.submit(create_item, "my item 2")
response1 = future1.result()
response2 = future2.result()
assert response1.status_code == 201
assert response2.status_code == 201
@@ -1,45 +0,0 @@
"""
Tests for files API endpoint in meet's core app: delete
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_delete_anonymous():
"""Anonymous users should not be allowed to destroy a file."""
file = factories.FileFactory()
existing_items = models.File.objects.all().count()
response = APIClient().delete(
f"/api/v1.0/files/{file.id!s}/",
)
assert response.status_code == 401
assert models.File.objects.count() == existing_items
def test_api_files_delete_authenticated_owner():
"""
Authenticated users should be able to delete a item they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(creator=user)
response = client.delete(
f"/api/v1.0/files/{file.id}/",
)
assert response.status_code == 204
# Make sure it is only a soft delete
file.refresh_from_db()
assert file.deleted_at is not None
@@ -1,160 +0,0 @@
"""
Tests for files API endpoint in meet's core app: list
"""
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
def test_api_files_list_anonymous_not_allowed():
"""
Anonymous users should not be allowed to list files whatever the
"""
response = APIClient().get("/api/v1.0/files/")
assert response.status_code == 401
def test_api_files_list_authentificated_user_allowed():
"""
Authentificated users should be allowed to list files
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
assert response.data == {"count": 0, "next": None, "previous": None, "results": []}
def test_api_files_list_format():
"""Validate the format of files as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
title="item 1",
creator=user,
)
# A file from another user should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
title="item 2",
)
# hard deleted item should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
hard_deleted_at=timezone.now(),
title="hard deleted item",
creator=user,
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results == [
{
"id": str(file.id),
"created_at": file.created_at.isoformat().replace("+00:00", "Z"),
"creator": {
"id": str(file.creator.id),
"full_name": file.creator.full_name,
"short_name": file.creator.short_name,
},
"title": file.title,
"updated_at": file.updated_at.isoformat().replace("+00:00", "Z"),
"type": models.FileTypeChoices.BACKGROUND_IMAGE,
"upload_state": file.upload_state,
"url": None,
"mimetype": file.mimetype,
"filename": file.filename,
"size": None,
"description": None,
"deleted_at": None,
"hard_deleted_at": None,
"abilities": {
"destroy": True,
"hard_delete": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
"upload_ended": True,
},
}
]
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_files_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file_ids = [
str(file.id)
for file in factories.FileFactory.create_batch(
3,
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
)
]
# Get page 1
response = client.get(
"/api/v1.0/files/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/files/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
file_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/files/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/files/"
assert len(content["results"]) == 1
for item in content["results"]:
file_ids.remove(item["id"])
assert file_ids == []
@@ -1,216 +0,0 @@
"""
Tests for files API endpoint in meet's core app: list
"""
import pytest
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
# Filters: unknown field
def test_api_files_list_filter_unknown_field():
"""
Trying to filter by an unknown field should do nothing.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory(type=models.FileTypeChoices.BACKGROUND_IMAGE)
expected_ids = {
str(file.id)
for file in factories.FileFactory.create_batch(
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
}
response = client.get("/api/v1.0/files/?unknown=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert {result["id"] for result in results} == expected_ids
# Filters: is_creator_me
def test_api_files_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter files they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
factories.FileFactory.create_batch(2, type=models.FileTypeChoices.BACKGROUND_IMAGE)
response = client.get("/api/v1.0/files/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == {
"id": str(user.id),
"full_name": user.full_name,
"short_name": user.short_name,
}
def test_api_files_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
3, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
response = client.get("/api/v1.0/files/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Filters: type
def test_api_files_list_filter_type_and_upload_status():
"""
Authenticated users should be able to filter files by their type and upload status.
This test will make more sense when other types are added to the API
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
assert file.upload_state == models.FileUploadStateChoices.PENDING
expected_files = factories.FileFactory.create_batch(
2,
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
expected_files_ids = {str(file.id) for file in expected_files}
# Filter by type: background_image & upload state
response = client.get("/api/v1.0/files/?type=background_image&upload_state=ready")
assert response.status_code == 200
assert response.json()["count"] == 2
results = response.json()["results"]
# Ensure all results are background images
results_ids = {result["id"] for result in results}
assert results_ids == expected_files_ids
for result in results:
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
assert result["upload_state"] == models.FileUploadStateChoices.READY
# Second request without the upload_state filter, to check that all 3 show up
response = client.get("/api/v1.0/files/?type=background_image")
assert response.status_code == 200
assert response.json()["count"] == 3
results = response.json()["results"]
# Ensure all results are background images
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in expected_files + [file]}
for result in results:
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
def test_api_files_list_filter_is_deleted():
"""
Authenticated users should be able to filter files by their deletion status.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
not_deleted_file = factories.FileFactory(creator=user)
deleted_files = factories.FileFactory.create_batch(2, creator=user)
for file in deleted_files:
file.soft_delete()
# No filters
response_no_filters = client.get("/api/v1.0/files/")
assert response_no_filters.status_code == 200
assert response_no_filters.json()["count"] == 3
results = response_no_filters.json()["results"]
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in [*deleted_files, not_deleted_file]}
# Filters deleted
response_filter_deleted = client.get("/api/v1.0/files/?is_deleted=true")
assert response_filter_deleted.status_code == 200
assert response_filter_deleted.json()["count"] == 2
results = response_filter_deleted.json()["results"]
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in deleted_files}
# Filters not deleted
response_filter_not_deleted = client.get("/api/v1.0/files/?is_deleted=false")
assert response_filter_not_deleted.status_code == 200
assert response_filter_not_deleted.json()["count"] == 1
results = response_filter_not_deleted.json()["results"]
# Ensure all results are deleted
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in [not_deleted_file]}
def test_api_files_list_filter_unknown_type():
"""
Filtering by an unknown type should return an empty list
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(3, creator=user)
response = client.get("/api/v1.0/files/?type=unknown")
assert response.status_code == 400
assert response.json() == {
"type": ["Select a valid choice. unknown is not one of the available choices."]
}
@@ -1,71 +0,0 @@
"""Test the ordering of items."""
import operator
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_list_ordering_default():
"""items should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Check that results are sorted by descending "updated_at" as expected
for i in range(3):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_files_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/files/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(3):
operator1 = (
results[i][field].lower()
if isinstance(results[i][field], str)
else results[i][field]
)
operator2 = (
results[i + 1][field].lower()
if isinstance(results[i + 1][field], str)
else results[i + 1][field]
)
assert compare(operator1, operator2)
@@ -1,141 +0,0 @@
"""
Test file uploads API endpoint for users in meet's core app.
"""
from io import BytesIO
from urllib.parse import quote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_media_auth_anonymous_not_authorized():
"""Anonymous users should not be allowed to retrieve a file"""
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
original_url = f"http://localhost/media/{file.file_key:s}"
response = APIClient().get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_files_media_get_own():
"""
Authenticated user should be allowed to retrieve their own file.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
original_url = f"http://localhost/media/{file.file_key:s}"
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{file.file_key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_files_media_auth_file_pending():
"""
Users who have a specific access to an file, whatever the role, should not be able to
retrieve related attachments if the file is not ready.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_state=models.FileUploadStateChoices.PENDING,
creator=user,
)
key = file.file_key
original_url = quote(f"http://localhost/media/{key:s}")
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_files_media_auth_own_file_deleted():
"""
This function tests the access restrictions on deleted files through the media
authorization API endpoint. It ensures that a user cannot retrieve a file that is deleted.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
file.soft_delete()
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -1,67 +0,0 @@
"""
Tests for files API endpoint in meet's core app: update
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_files_update_anonymous_forbidden():
"""
Anonymous users should not be allowed to update an file when link
configuration does not allow it.
"""
file = factories.FileFactory()
old_file_values = serializers.FileSerializer(instance=file).data
new_file_values = serializers.FileSerializer(instance=factories.FileFactory()).data
response = APIClient().put(
f"/api/v1.0/files/{file.id!s}/",
new_file_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
file.refresh_from_db()
item_values = serializers.FileSerializer(instance=file).data
assert item_values == old_file_values
def test_api_files_update_description_and_title():
"""
Test the description and title of a file can be updated.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
description="Old description",
title="Old title",
creator=user,
)
response = client.patch(
f"/api/v1.0/files/{file.id!s}/",
{"description": "New description", "title": "New title"},
format="json",
)
assert response.status_code == 200
result = response.json()
assert result["description"] == "New description"
assert result["title"] == "New title"
file.refresh_from_db()
assert file.description == "New description"
assert file.title == "New title"
@@ -1,272 +0,0 @@
"""Test related to item upload ended API."""
import logging
from io import BytesIO
from django.core.files.storage import default_storage
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.models import FileTypeChoices, FileUploadStateChoices
pytestmark = pytest.mark.django_db
def test_api_file_upload_ended_anonymous():
"""Anonymous users should not be allowed to end an upload."""
file = factories.FileFactory()
response = APIClient().post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 401
def test_api_file_upload_ended_non_creator_not_found():
"""Users without write permissions should not be allowed to end an upload."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory()
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 404
def test_api_file_upload_ended_on_wrong_upload_state():
"""
Users should not be allowed to end an upload on files that are not in the PENDING upload state.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
creator=user,
update_upload_state=FileUploadStateChoices.READY,
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 400
assert response.json() == {
"file": "This action is only available for files in PENDING state."
}
def test_api_file_upload_ended_success(settings):
"""
Users should be able to end an upload on files that are files and in the UPLOADING upload state.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/html", "text/plain"],
},
}
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.txt",
mimetype="text/html",
creator=user,
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 200
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
assert file.mimetype == "text/plain"
assert file.size == 8
assert response.json()["mimetype"] == "text/plain"
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
"""
Test that the API returns a 400 when the mimetype is not allowed.
File should be deleted and the file should be deleted from the storage.
"""
settings.RESTRICT_UPLOAD_FILE_TYPE = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
with caplog.at_level(logging.WARNING):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 400
assert (
f"upload_ended: mimetype not allowed text/plain for file {file.file_key}"
in caplog.text
)
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
"""
Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 200
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
assert file.mimetype == "text/plain"
assert file.size == 8
assert response.json()["mimetype"] == "text/plain"
def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog):
"""
Object on storage should have the same mimetype than the one saved in the
File object.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/html", "application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.pdf",
title="my_file.pdf",
creator=user,
)
s3_client = default_storage.connection.meta.client
s3_client.put_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
ContentType="text/html",
Body=BytesIO(
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
),
Metadata={
"foo": "bar",
},
)
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "text/html"
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert (
"upload_ended: content type mismatch between object storage and file,"
" updating from text/html to application/pdf" in caplog.text
)
assert response.status_code == 200
file.refresh_from_db()
assert file.mimetype == "application/pdf"
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "application/pdf"
assert head_object["Metadata"] == {"foo": "bar"}
def test_api_upload_ended_file_size_exceeded(settings, caplog):
"""
Test when the file size exceed the allowed max upload file size
should return a 400 and delete the file.
"""
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_size": 0,
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert (
f"upload_ended: file size (8) for file {file.file_key} higher than the allowed max size"
in caplog.text
)
assert response.status_code == 400
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
@@ -32,7 +32,7 @@ def valid_minio_event():
"s3": { "s3": {
"bucket": {"name": "test-bucket"}, "bucket": {"name": "test-bucket"},
"object": { "object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", "key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg", "contentType": "audio/ogg",
}, },
} }
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event.""" """Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event) event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent) assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg" assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg" assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket" assert event.bucket_name == "test-bucket"
assert event.metadata is None assert event.metadata is None
@@ -130,13 +130,11 @@ def test_validate_invalid_filetype(minio_parser):
"invalid_filepath", "invalid_filepath",
[ [
"invalid_filepath", # totally invalid string "invalid_filepath", # totally invalid string
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", "recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension "recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension "46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string "", # empty string
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all "recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
], ],
) )
def test_validate_invalid_filepath(invalid_filepath, minio_parser): def test_validate_invalid_filepath(invalid_filepath, minio_parser):
@@ -154,7 +152,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
def test_validate_valid_event(minio_parser): def test_validate_valid_event(minio_parser):
"""Test validation with valid event data.""" """Test validation with valid event data."""
event = StorageEvent( event = StorageEvent(
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg", filetype="audio/ogg",
bucket_name="test-bucket", bucket_name="test-bucket",
metadata=None, metadata=None,
@@ -172,7 +170,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
def test_validate_filepath_with_folder(minio_parser): def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure.""" """Test validation of filepath with folder structure."""
event = StorageEvent( event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg", filetype="audio/ogg",
bucket_name="test-bucket", bucket_name="test-bucket",
metadata=None, metadata=None,
@@ -221,7 +219,7 @@ def test_validate_custom_filetypes():
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"}) parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent( event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3", filetype="audio/mp3",
bucket_name="test-bucket", bucket_name="test-bucket",
metadata=None, metadata=None,
@@ -14,7 +14,6 @@ from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import ( from ...recording.event.exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError, InvalidFileTypeError,
ParsingEventDataError, ParsingEventDataError,
) )
@@ -95,7 +94,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
) )
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."} assert response.json() == {"detail": "Invalid request data: Error message"}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client): def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -112,7 +111,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
) )
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."} assert response.json() == {"detail": "Invalid bucket specified"}
def test_save_recording_filetype_error(recording_settings, mock_get_parser): def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -133,28 +132,7 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."} assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client): def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
@@ -240,9 +240,10 @@ def test_api_rooms_invite_error(mock_invite_to_room):
mock_invite_to_room.assert_called_once() mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.EmailMultiAlternatives") @mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_email_class, settings): def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails.""" """Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME" settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo" settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com" settings.EMAIL_APP_BASE_URL = "https://acme.com"
@@ -254,6 +255,7 @@ def test_api_rooms_invite_success(mock_email_class, settings):
user = UserFactory() user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"])) room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user) client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]} data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
@@ -267,38 +269,26 @@ def test_api_rooms_invite_success(mock_email_class, settings):
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"} assert response.json() == {"status": "success", "message": "invitations sent"}
mock_email_class.assert_called_once() mock_send_mail.assert_called_once()
# Check constructor arguments subject, body, sender, recipients = mock_send_mail.call_args[0]
call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs)
assert call_kwargs["subject"] == ( assert (
f"Video call in progress: {user.email} is waiting for you to connect" subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
assert call_kwargs["from_email"] == "notifications@acme.com"
assert call_kwargs["to"] == []
assert sorted(call_kwargs["bcc"]) == sorted(
["fabien@yopmail.com", "gerald@yopmail.com"]
) )
# Check plain text body # Verify email contains expected content
plain_body = call_kwargs["body"]
required_content = [ required_content = [
"ACME", "ACME", # Brand name
"https://acme.com/logo", "https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", f"acme.com/{room.slug}", # Room link
] ]
for content in required_content:
assert content in plain_body
# Check HTML alternative was attached
mock_instance = mock_email_class.return_value
mock_instance.attach_alternative.assert_called_once()
html_body, mimetype = mock_instance.attach_alternative.call_args[0]
assert mimetype == "text/html"
for content in required_content: for content in required_content:
assert content in html_body assert content in body
# Check send was called assert sender == "notifications@acme.com"
mock_instance.send.assert_called_once()
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -120,39 +120,3 @@ def test_api_rooms_list_authenticated_distinct():
content = response.json() content = response.json()
assert len(content["results"]) == 1 assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(room.id) assert content["results"][0]["id"] == str(room.id)
def test_api_rooms_list_pagination_page_size():
"""Users should be able to customize the number of results per page via page_size param."""
user = UserFactory()
client = APIClient()
client.force_login(user)
RoomFactory.create_batch(11, users=[user])
response = client.get("/api/v1.0/rooms/?page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 2
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=2"
assert content["previous"] is None
response = client.get("/api/v1.0/rooms/?page=6&page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 1
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/rooms/?page=5&page_size=2"
response = client.get("/api/v1.0/rooms/?page_size=3")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None
@@ -631,109 +631,3 @@ def test_list_waiting_participants_empty(settings):
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"participants": []} assert response.json() == {"participants": []}
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_without_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_with_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_authenticated_user(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Authenticated users should be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@@ -8,7 +8,6 @@ import random
from unittest import mock from unittest import mock
from uuid import uuid4 from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse from django.urls import reverse
import pytest import pytest
@@ -130,11 +129,14 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True, "can_publish": True,
"can_publish_data": True, "can_publish_data": True,
"can_publish_sources": [ "can_publish_sources": [
"CAMERA", 1,
"MICROPHONE", 2,
], ], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True, "can_update_metadata": True,
"can_subscribe_metrics": True, "agent": False,
"can_subscribe_metrics": False,
}, },
"name": "John Doe", "name": "John Doe",
} }
@@ -149,128 +151,6 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"permission_payload",
[
{}, # empty dict is valid
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
]
},
{"can_update_metadata": True},
{"can_subscribe_metrics": False},
],
)
def test_update_participant_permission_fields_are_optional(
mock_livekit_client, permission_payload
):
"""Test that each required permission field can be passed individually."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": permission_payload,
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"value,permission_key",
[
(False, "hidden"),
(True, "hidden"),
(False, "recorder"),
(True, "recorder"),
(False, "agent"),
(True, "agent"),
],
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission(
mock_suspicious, value, permission_key
):
"""Test update participant raises 400 when a restricted permission is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
permission_key: value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
f"Setting the following participant permissions is not allowed: {permission_key}."
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
"""Test update participant raises 400 when multiple suspicious permissions are set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"hidden": True,
"recorder": False,
"can_update_metadata": False,
"agent": True,
"can_subscribe_metrics": False,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
)
def test_update_participant_forbidden_without_access(): def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges.""" """Test update participant returns 403 when user lacks room privileges."""
client = APIClient() client = APIClient()
@@ -346,17 +226,7 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json") response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == { assert "Invalid permission" in str(response.data)
"permission": [
{
"type": "extra_forbidden",
"loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted",
"input": "True",
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
},
]
}
def test_update_participant_wrong_metadata_attributes(): def test_update_participant_wrong_metadata_attributes():
@@ -199,308 +199,3 @@ def test_start_recording_success(
access = recording.accesses.first() access = recording.accesses.first()
assert access.user == user assert access.user == user
assert access.role == "owner" assert access.role == "owner"
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
def test_start_recording_options_language_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept a valid ISO 639-1 language code."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": value}
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
def test_start_recording_options_language_not_validated(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Invalid language codes are currently accepted — no format validation yet.
TODO: tighten this once language validation is introduced.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_language_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept null language (triggers auto-detection)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
def test_start_recording_options_transcribe_valid_true(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": True}
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
def test_start_recording_options_transcribe_valid_false(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic false values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": False}
def test_start_recording_options_transcribe_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept transcribe=null (falls back to application default)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept options=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": None},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with no options field at all."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_unknown_field_rejected(settings):
"""Should reject unknown fields in options (extra='forbid')."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
def test_start_recording_options_original_mode_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept valid recording mode choices for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"original_mode": value}
def test_start_recording_options_original_mode_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept original_mode=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_original_mode_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with original_mode omitted."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 400
@@ -77,6 +77,7 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401 assert response.status_code == 401
assert response.json() == { assert response.json() == {
"status": "error", "status": "error",
"message": "Authorization header missing",
} }
@@ -90,7 +91,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == {"status": "error"} assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
def test_unknown_event_type(client, mock_livekit_config): def test_unknown_event_type(client, mock_livekit_config):
@@ -115,6 +116,7 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422 assert response.status_code == 422
assert response.json() == { assert response.json() == {
"status": "error", "status": "error",
"message": "Unknown webhook type: unknown_event_type",
} }
@@ -904,7 +904,7 @@ def test_api_rooms_token_unknown_application(settings):
def test_api_rooms_token_inactive_application(settings): def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected.""" """Token for inactive application should be rejected."""
application = ApplicationFactory(is_active=False) application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {
@@ -23,7 +23,7 @@ def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token.""" """Valid credentials should return a JWT token."""
UserFactory(email="User.Family@example.com") UserFactory(email="User.Family@example.com")
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -79,7 +79,7 @@ def test_api_applications_generate_token_invalid_client_id():
def test_api_applications_generate_token_invalid_client_secret(): def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401.""" """Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
client = APIClient() client = APIClient()
response = client.post( response = client.post(
@@ -100,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
def test_api_applications_generate_token_inactive_application(): def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401.""" """Inactive application should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False) application = ApplicationFactory(active=False)
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
application.client_secret = plain_secret application.client_secret = plain_secret
@@ -122,31 +122,9 @@ def test_api_applications_generate_token_inactive_application():
assert "Application is inactive" in str(response.data) assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_inactive_application_wrong_secret():
"""An inactive application with a wrong secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
assert "inactive" not in str(response.data).lower()
def test_api_applications_generate_token_invalid_email_format(): def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400.""" """Invalid email format should return 400."""
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
application.client_secret = plain_secret application.client_secret = plain_secret
@@ -171,7 +149,7 @@ def test_api_applications_generate_token_invalid_email_format():
def test_api_applications_generate_token_domain_not_authorized(): def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403.""" """Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com") user = UserFactory(email="user@denied.com")
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
ApplicationDomainFactory(application=application, domain="allowed.com") ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
@@ -198,7 +176,7 @@ def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed.""" """Application with domain authorization should succeed."""
user = UserFactory(email="user@allowed.com") user = UserFactory(email="user@allowed.com")
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST], scopes=[ApplicationScope.ROOMS_LIST],
) )
ApplicationDomainFactory(application=application, domain="allowed.com") ApplicationDomainFactory(application=application, domain="allowed.com")
@@ -225,7 +203,7 @@ def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_user_not_found(): def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404.""" """Non-existent user should return 404."""
application = ApplicationFactory(is_active=True) application = ApplicationFactory(active=True)
plain_secret = "test-secret-123" plain_secret = "test-secret-123"
application.client_secret = plain_secret application.client_secret = plain_secret
@@ -253,7 +231,7 @@ def test_api_applications_token_payload_structure(settings):
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -306,7 +284,7 @@ def test_api_applications_token_new_user(settings):
assert len(User.objects.all()) == 0 assert len(User.objects.all()) == 0
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -364,7 +342,7 @@ def test_api_applications_token_existing_user(settings):
assert len(User.objects.all()) == 1 assert len(User.objects.all()) == 1
application = ApplicationFactory( application = ApplicationFactory(
is_active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
) )
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
def test_models_application_active_default(): def test_models_application_active_default():
"""An application should be active by default.""" """An application should be active by default."""
application = Application.objects.create(name="Test App") application = Application.objects.create(name="Test App")
assert application.is_active is True assert application.active is True
def test_models_application_scopes_default(): def test_models_application_scopes_default():
@@ -1,166 +0,0 @@
"""
Test utils.detect_mimetype
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
"""
import pytest
from core import utils
def test_detect_mimetype_from_content_pdf():
"""Test detect_mimetype detects PDF from content (magic bytes)."""
# PDF magic bytes: %PDF
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename="document.pdf")
assert mimetype == "application/pdf"
def test_detect_mimetype_from_content_png():
"""Test detect_mimetype detects PNG from content (magic bytes)."""
# PNG magic bytes: \x89PNG\r\n\x1a\n
png_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
mimetype = utils.detect_mimetype(png_content, filename="image.png")
assert mimetype == "image/png"
def test_detect_mimetype_from_content_jpeg():
"""Test detect_mimetype detects JPEG from content (magic bytes)."""
# JPEG magic bytes: \xff\xd8\xff
jpeg_content = b"\xff\xd8\xff\xe0\x00\x10JFIF"
mimetype = utils.detect_mimetype(jpeg_content, filename="photo.jpg")
assert mimetype == "image/jpeg"
def test_detect_mimetype_from_content_text_plain():
"""Test detect_mimetype detects plain text from content."""
text_content = b"This is plain text content"
mimetype = utils.detect_mimetype(text_content, filename="file.txt")
assert mimetype == "text/plain"
def test_detect_mimetype_empty_file():
"""Test detect_mimetype handles empty files."""
empty_content = b""
mimetype = utils.detect_mimetype(empty_content, filename="empty.txt")
# Empty files should be detected as application/x-empty by magic bytes
assert mimetype == "application/x-empty"
def test_detect_mimetype_uses_extension_when_content_generic():
"""Test detect_mimetype uses extension when content detection returns generic type."""
# Generic binary content that might be detected as application/octet-stream
generic_content = b"\x00\x01\x02\x03\x04\x05"
# But if we have a specific extension, we should use it
mimetype = utils.detect_mimetype(generic_content, filename="document.pdf")
# Should prefer extension-based detection for specific types
assert mimetype == "application/pdf"
def test_detect_mimetype_uses_extension_for_json():
"""Test detect_mimetype uses extension for JSON files."""
# JSON content might be detected as text/plain
json_content = b'{"key": "value"}'
mimetype = utils.detect_mimetype(json_content, filename="data.json")
# Should use extension to get application/json
assert mimetype == "application/json"
def test_detect_mimetype_without_filename():
"""Test detect_mimetype works without filename (content-only detection)."""
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename=None)
assert mimetype == "application/pdf"
def test_detect_mimetype_fallback_to_extension():
"""Test detect_mimetype falls back to extension when content detection is generic."""
# Content that might be detected as text/plain
content = b"some content"
mimetype = utils.detect_mimetype(content, filename="script.js")
# Should use extension to get JavaScript MIME type
# (can be text/javascript or application/javascript)
assert mimetype in ["text/javascript", "application/javascript"]
def test_detect_mimetype_generic_content_no_extension():
"""Test detect_mimetype with generic content and no extension."""
generic_content = b"\x00\x01\x02\x03"
mimetype = utils.detect_mimetype(generic_content, filename="file")
# Should return content-based detection (likely application/octet-stream)
assert mimetype in ["application/octet-stream", "application/x-empty"]
def test_detect_mimetype_xml_file():
"""Test detect_mimetype detects XML files."""
xml_content = b'<?xml version="1.0"?><root></root>'
mimetype = utils.detect_mimetype(xml_content, filename="data.xml")
# Should detect as XML (either from content or extension)
assert mimetype in ["application/xml", "text/xml"]
def test_detect_mimetype_csv_file():
"""Test detect_mimetype detects CSV files."""
csv_content = b"name,age\nJohn,30\nJane,25"
mimetype = utils.detect_mimetype(csv_content, filename="data.csv")
# CSV might be detected as text/plain, but extension should help
assert mimetype in ["text/csv", "text/plain"]
def test_detect_mimetype_zip_file():
"""Test detect_mimetype detects ZIP files from magic bytes."""
# ZIP magic bytes: PK\x03\x04
zip_content = b"PK\x03\x04\x14\x00\x00\x00"
mimetype = utils.detect_mimetype(zip_content, filename="archive.zip")
assert mimetype == "application/zip"
def test_detect_mimetype_prefers_content_over_extension():
"""Test detect_mimetype prefers content detection when both are available and specific."""
# PDF content but wrong extension
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename="document.txt")
# Should prefer content detection (PDF) over extension (txt)
assert mimetype == "application/pdf"
@pytest.mark.xfail(
reason="Fails in our repo, but passes in the original repo, leaving it there"
)
def test_detect_mimetype_powerpoint_pptx():
"""Test detect_mimetype correctly detects PowerPoint .pptx files."""
# .pptx files are ZIP archives, so content might be detected as application/zip or octet-stream
# But with the extension, it should be detected as PowerPoint MIME type
# Using minimal ZIP-like content that might be detected as generic
pptx_content = b"PK\x03\x04" # ZIP magic bytes (PPTX is a ZIP archive)
mimetype = utils.detect_mimetype(pptx_content, filename="presentation.pptx")
# Should use extension to get PowerPoint MIME type
assert mimetype in [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
]
def test_detect_mimetype_powerpoint_ppt():
"""Test detect_mimetype correctly detects PowerPoint .ppt files (older format)."""
# .ppt files might be detected as application/octet-stream by magic bytes
# But with the extension, it should be detected as PowerPoint MIME type
ppt_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # OLE2 compound document header
mimetype = utils.detect_mimetype(ppt_content, filename="presentation.ppt")
# Should use extension to get PowerPoint MIME type
assert mimetype in [
"application/vnd.ms-powerpoint",
"application/mspowerpoint",
]
def test_detect_mimetype_ole_storage():
"""
Microsoft files .xls .doc .ppt can use the mimetype application/x-ole-storage
for Microsoft OLE2/Compound File Binary Format. In that case we want to rely on the extension
"""
xls_ole_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
mimetype = utils.detect_mimetype(xls_ole_content, filename="document.xls")
assert mimetype == "application/vnd.ms-excel"

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