Compare commits
371 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d7f2957f9 | |||
| d83d3ccc18 | |||
| 52a9a583e8 | |||
| 07698ddced | |||
| cce0a45fbc | |||
| da1767c261 | |||
| dd3d47afe6 | |||
| ac2eddc10f | |||
| 35863ec3b6 | |||
| 3d125e940f | |||
| 6f38d60a27 | |||
| 012857f8c6 | |||
| e5a804f748 | |||
| 6bb8084aa0 | |||
| 4548f69de8 | |||
| 1a3a92f901 | |||
| 181b97b310 | |||
| bb816eb2e5 | |||
| 6ee89b201e | |||
| ecb710688d | |||
| 5e1e05b001 | |||
| 211e97edfa | |||
| c4fc46727c | |||
| 28acbb5459 | |||
| 5a81e2b92c | |||
| df24aaab71 | |||
| 3b474ba1c0 | |||
| fc4b6d679a | |||
| 73dd684c8d | |||
| 8507cdd2b6 | |||
| aaf21e97e8 | |||
| bd3a26a2af | |||
| 4d222e4ab4 | |||
| b80c46da54 | |||
| 451be40bb7 | |||
| 45c5a443fb | |||
| 34f9dea73f | |||
| f0fda145d9 | |||
| d12ced352a | |||
| 497b45f2ca | |||
| 52fbd56666 | |||
| 170763a1f7 | |||
| 037166fb21 | |||
| 6374e136d8 | |||
| 3ccb2d4dd8 | |||
| 5d7a54e809 | |||
| 07af7a85ff | |||
| 6180ac4e4f | |||
| a30b573d36 | |||
| 83b95c5520 | |||
| 0c9b83c793 | |||
| 812d80c4f2 | |||
| 34212be6e2 | |||
| d8ccd02bb2 | |||
| 08aa63ecb2 | |||
| bbc8f61221 | |||
| 6b656eefd7 | |||
| 264f267ac3 | |||
| 4bf3ba4c48 | |||
| 4fdc2eee11 | |||
| 19c2a378e7 | |||
| 5a70604f01 | |||
| 7e422e5846 | |||
| d915b93caa | |||
| 9d9ec794aa | |||
| 570d57d5f5 | |||
| 7469ccfdf1 | |||
| 0d3bd2727e | |||
| 660b022eb1 | |||
| a28b611ecc | |||
| dfa6092c72 | |||
| 7d8c166c7f | |||
| f7dd1f8fd7 | |||
| 416411b843 | |||
| 45e0665cf0 | |||
| a3eabf8f66 | |||
| 7c81947681 | |||
| 2424817523 | |||
| 15133f9d6b | |||
| bea1f18ab8 | |||
| d5a614d2b5 | |||
| 108db2e3e5 | |||
| 73496406e8 | |||
| c5c96369c8 | |||
| e9f90e95b1 | |||
| f57fbf2d35 | |||
| 920f4558fc | |||
| 9df901b9d6 | |||
| c09c440631 | |||
| cd7ce77074 | |||
| 6d3c26419d | |||
| 9dbc38984e | |||
| 1bd5a294e4 | |||
| 4d98ed4977 | |||
| bf32c073c6 | |||
| 4f2c4bfaf9 | |||
| dacf705329 | |||
| 8296738347 | |||
| 04be495351 | |||
| 43185605eb | |||
| cf3fb208e2 | |||
| 4ca230eb12 | |||
| 4b5e0cb2a3 | |||
| 45f374610f | |||
| b419a2bfd2 | |||
| ee8d96bee7 | |||
| c65ff2d75d | |||
| ea1c90d8ca | |||
| 3e963e3e6d | |||
| 82769128a1 | |||
| 05f4ce6b2e | |||
| 508984ecfa | |||
| a05507f73d | |||
| a27def119c | |||
| fce94f38ce | |||
| 83bd9c5ce3 | |||
| bfbfade99a | |||
| 16daf7b8d3 | |||
| fcad79d662 | |||
| 2424ce17ec | |||
| 4715905334 | |||
| 7c05aedcfe | |||
| 7347fc7c86 | |||
| ada7d9a666 | |||
| 0cb3fb8e3c | |||
| dcb788b57b | |||
| 73bcb9d598 | |||
| f3e90c3999 | |||
| cb4ed3c9d7 | |||
| f8b0746e73 | |||
| d8ad7a743e | |||
| 0ba445895c | |||
| 3b719ab9ba | |||
| 8887e811d3 | |||
| cb9e994749 | |||
| 1841533d2c | |||
| 7da99f2116 | |||
| b0af5e7f35 | |||
| dd6bb0ed3e | |||
| 1306f0bcfe | |||
| ca4494c09e | |||
| f57db0acc8 | |||
| 342f992556 | |||
| d0cf3974ad | |||
| 0f3eb35c83 | |||
| d15fb37932 | |||
| 051f33bb1e | |||
| 566eacc8fe | |||
| d19023a1ba | |||
| a6e36f02a7 | |||
| c3fd1a89ef | |||
| 8b1ff536b3 | |||
| 6b08b8da1b | |||
| c4ff42f181 | |||
| ee0aa0fe5b | |||
| 09c871fc29 | |||
| 8496959188 | |||
| 77105001e0 | |||
| 2d57d38644 | |||
| c9d13619a6 | |||
| b99ec9bb50 | |||
| b3f26469c8 | |||
| d5c53c7dd4 | |||
| f43ac2e4eb | |||
| ea5dd5bc0e | |||
| 86427fa2b7 | |||
| 3959c3657c | |||
| 191f8abbcc | |||
| d91f8bb6e1 | |||
| d612f9b26b | |||
| 042be17cfa | |||
| d00f4fa695 | |||
| fd36469fc2 | |||
| dc278a6064 | |||
| 047da94494 | |||
| 124a8bf8d9 | |||
| c72c5cae1a | |||
| b564044e70 | |||
| 4717143251 | |||
| 805e983749 | |||
| 6dfafb7f67 | |||
| e56c0f997e | |||
| 61afd94e3a | |||
| 3d7aec2b4a | |||
| 9c009839f0 | |||
| ec63ddcd47 | |||
| fcde8757e6 | |||
| 9610e606eb | |||
| 8362ac0e24 | |||
| f1ddd7fa2f | |||
| 487340efca | |||
| 7ebc928dd3 | |||
| 85de214ca7 | |||
| e3e34dbf31 | |||
| 555afe4abd | |||
| 78ddb121e3 | |||
| ca9c7fc152 | |||
| 6e3845d0c1 | |||
| 41b171da68 | |||
| 4ad897e756 | |||
| 42647d6d25 | |||
| 14526808ab | |||
| 25167495cc | |||
| 720eb6a93e | |||
| bfbf253033 | |||
| 692e0e359e | |||
| 1d23cb889a | |||
| b2ad423886 | |||
| 2c7b4bea04 | |||
| 1eda18ea6e | |||
| 8d5488c333 | |||
| 5c0e6b6479 | |||
| 077cf59082 | |||
| 4881fa20f5 | |||
| 116db1e697 | |||
| 4b76e9571f | |||
| e8739d7e70 | |||
| 602bcf3185 | |||
| f5e0ddf692 | |||
| cd0cec78ba | |||
| e647787170 | |||
| d76b4c9b9f | |||
| 09c7edecb8 | |||
| f625df6508 | |||
| ac87980a27 | |||
| 7cab46dc29 | |||
| 259b739160 | |||
| 6f77559633 | |||
| 2cdf19de77 | |||
| fcf08a6dbd | |||
| 7bf623f654 | |||
| 1c1d1938d9 | |||
| ddb81765f3 | |||
| 8ca52737cd | |||
| 87b9ca2314 | |||
| 8a6419da44 | |||
| 127d4e1d5a | |||
| 99cbc1f784 | |||
| 246312c51c | |||
| 1b09683938 | |||
| db3d3d61ef | |||
| c1a797c2c1 | |||
| 4d6a7573c4 | |||
| 0b73fd8f06 | |||
| e362765b21 | |||
| be79fdac80 | |||
| 75a15a0004 | |||
| 3087dfe486 | |||
| 9916ab7d7e | |||
| bd2ad3bb99 | |||
| f02fbc85a3 | |||
| 4fd4e074e0 | |||
| ec3d4f7462 | |||
| 4507325331 | |||
| dac4a72838 | |||
| 5048005fc1 | |||
| 002c7c0e42 | |||
| e18b732776 | |||
| ce9f812a7e | |||
| b02591170f | |||
| e58181f846 | |||
| d37f47e82c | |||
| db80c09c10 | |||
| fd9f2a81ca | |||
| d865db5f1b | |||
| 7cc5b2b961 | |||
| c85977cb68 | |||
| 3c3b4a32e3 | |||
| 9b033c55b2 | |||
| a2c7becaf4 | |||
| 89031abb63 | |||
| fc92fa4eb4 | |||
| 2c65cc061e | |||
| bfadeae6ee | |||
| 117677bd14 | |||
| 69c6e58017 | |||
| 6742f5d19d | |||
| 23de7e52bc | |||
| 3887255e9c | |||
| 5d6ad3f3f6 | |||
| 44d68a9c80 | |||
| ed5c1bbd84 | |||
| f8c6da8021 | |||
| 5ba1657e00 | |||
| c28b8ba902 | |||
| 6962367e18 | |||
| 0bd57e8623 | |||
| 27f2023104 | |||
| 44362eca23 | |||
| c34a85699b | |||
| 12d8c4a9db | |||
| 42a05da5c0 | |||
| 4344dd6e35 | |||
| fe28902b2e | |||
| 1e1e1a2657 | |||
| f4e48dafac | |||
| 9f58efb851 | |||
| 716e11b5b3 | |||
| 88a1136dfd | |||
| 90633928a8 | |||
| fd894eb61f | |||
| bb64532cff | |||
| 692c55ed1b | |||
| df616ae711 | |||
| 021d7a7e06 | |||
| f2a3e7c8de | |||
| cf07ceb67e | |||
| ea7fb5fc27 | |||
| 6e8a6ce82a | |||
| ce960ae330 | |||
| f9dd2e1909 | |||
| 9023e54352 | |||
| 8295574616 | |||
| db15c8b6cc | |||
| e1aeec6053 | |||
| c5aa762e11 | |||
| 8f710a4626 | |||
| 60d1338eff | |||
| f8436d9ae2 | |||
| 39fb273201 | |||
| d101459115 | |||
| 88696a23fd | |||
| 13d26a76b3 | |||
| b675517a60 | |||
| a5254ffd59 | |||
| ff82bca9ec | |||
| 99a18b6e90 | |||
| 250e599465 | |||
| 144a4e1b85 | |||
| 78ab3cdbdf | |||
| a815d6c00d | |||
| dfbc3a9d17 | |||
| 086db3d089 | |||
| 014ef3d804 | |||
| de3e1a56a8 | |||
| 459749b992 | |||
| e1450329f2 | |||
| c7e3194331 | |||
| 902b005f32 | |||
| 51d22783b2 | |||
| 76f80a0f2f | |||
| 82eb930200 | |||
| eeeb950e08 | |||
| cb77688572 | |||
| f9524b2f0a | |||
| a50aabeaf8 | |||
| 594bd5a692 | |||
| 69d92e6f30 | |||
| c47e830b40 | |||
| d7f1b7b94c | |||
| 8072d2c950 | |||
| 726f9097f9 | |||
| bbc7fa8012 | |||
| 41db3e766b | |||
| 1ab3ce6d47 | |||
| 3cd5c77f42 | |||
| 3ddb075c6b | |||
| 9ed2500565 | |||
| 1001783d3c | |||
| 97b5e8780c | |||
| 7c7074aa99 | |||
| 35b3bcad63 | |||
| 137a2c7f6f | |||
| d681e25bcc | |||
| 1f1a6371b4 | |||
| bbfbb23be5 | |||
| 6e20bc1f43 | |||
| fed05f2396 | |||
| 865acf2838 | |||
| 6ae68013af | |||
| 394a1be322 |
@@ -4,7 +4,7 @@ __pycache__
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
**/.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
@@ -24,13 +24,15 @@ data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Crowdin files
|
||||
uses: crowdin/github-action@v2
|
||||
|
||||
@@ -12,6 +12,9 @@ on:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||
@@ -20,10 +23,18 @@ env:
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -49,6 +60,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -56,10 +68,18 @@ jobs:
|
||||
|
||||
build-and-push-frontend-generic:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -86,6 +106,7 @@ jobs:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -93,10 +114,18 @@ jobs:
|
||||
|
||||
build-and-push-frontend-dinum:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -123,6 +152,7 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -130,10 +160,18 @@ jobs:
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -162,6 +200,7 @@ jobs:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -169,10 +208,18 @@ jobs:
|
||||
|
||||
build-and-push-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -201,12 +248,15 @@ jobs:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build-and-push-frontend-generic
|
||||
- build-and-push-frontend-dinum
|
||||
|
||||
@@ -7,14 +7,18 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
@@ -39,9 +43,11 @@ jobs:
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
@@ -49,9 +55,11 @@ jobs:
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
@@ -62,20 +70,22 @@ jobs:
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -95,43 +105,49 @@ jobs:
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint meet demo core
|
||||
run: uv run pylint meet demo core
|
||||
|
||||
lint-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agents
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
@@ -144,14 +160,16 @@ jobs:
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
@@ -165,7 +183,8 @@ jobs:
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
@@ -204,8 +223,6 @@ jobs:
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
@@ -213,10 +230,11 @@ jobs:
|
||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||
OIDC_OP_URL: https://oidc.example.com
|
||||
MEDIA_BASE_URL: http://localhost:8083
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
@@ -224,7 +242,7 @@ jobs:
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -258,13 +276,13 @@ jobs:
|
||||
mc mb meet/meet-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
@@ -272,16 +290,57 @@ jobs:
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
run: uv run pytest -n 2
|
||||
|
||||
test-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
|
||||
env:
|
||||
V1_TENANT_ID: 'test-tenant'
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL: "large-v2"
|
||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
||||
LLM_BASE_URL: "https://configure-your-url.com"
|
||||
LLM_API_KEY: "test-llm-secret"
|
||||
LLM_MODEL: "test-llm-model"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Run summary tests
|
||||
run: ~/.local/bin/pytest
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci
|
||||
@@ -294,12 +353,14 @@ jobs:
|
||||
|
||||
lint-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -312,13 +373,15 @@ jobs:
|
||||
|
||||
build-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs: lint-sdk
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# /!\
|
||||
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||
name: Security Review
|
||||
|
||||
permissions:
|
||||
pull-requests: write # Needed for leaving PR comments
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||
with:
|
||||
comment-pr: true
|
||||
exclude-directories: docs,gitlint,LICENSES,bin
|
||||
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||
@@ -31,6 +31,7 @@ MANIFEST
|
||||
|
||||
# Translations # Translations
|
||||
*.pot
|
||||
*.mo
|
||||
|
||||
# Environments
|
||||
.env
|
||||
|
||||
@@ -8,17 +8,284 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add metadata collection of VAD, connection and chat events
|
||||
- ✨(backend) introduce add-ons authentication backend
|
||||
- 💬(backend) clarify french transcription audio download link text #1299
|
||||
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
|
||||
- 🔧(backend) add setting to toggle application token exchange mechanism
|
||||
- ✨(backend) support add-ons authentication in external viewset
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(summary) support webm #1290
|
||||
- ⬆️(backend) bump django-lasuite to v0.0.26
|
||||
- 🩹(frontend) use a more standard (quality) rating scale
|
||||
- 🩹(frontend) fix access control for screen recording feature flag
|
||||
- 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
|
||||
|
||||
## [1.14.0] - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒️(helm) Add pod and container securityContext #1197
|
||||
- ✨(summary) add routes v2 for async STT and summary tasks #1171
|
||||
- ✅(backend) add unit tests for JwtTokenService #1232
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
|
||||
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
|
||||
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
|
||||
- ♻️(backend) use Authorization header for LiveKit token authentication
|
||||
- 🥅(backend) refine Twirp error handling for participant operations
|
||||
- ✨(summary) allow more file extensions #1265
|
||||
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
|
||||
- ♿️(frontend) set an explicit document title on recording download page #1261
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
|
||||
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
|
||||
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
|
||||
- 🔒(backend) rely on backend to allow participant update their metadata
|
||||
- 🐛(summary) fix failure webhook notification #1233
|
||||
- 🐛(summary) relax whisperX payload format #1233
|
||||
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
|
||||
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
|
||||
|
||||
## [1.13.0] - 2026-03-31
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
- ♿️(frontend) add explicit region for call controls #1216
|
||||
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
|
||||
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
|
||||
- 🐛(backend) fix regression in update-participant endpoint #1204
|
||||
|
||||
## [1.12.0] - 2026-03-24
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
|
||||
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
||||
- 💄(spinner) enforce spinner height #1183
|
||||
- 💄(custom-background) add upload indicator with preview #1183
|
||||
- ♿️(backend) improve logo accessibility in recording email notification #1092
|
||||
- ♿️(summary) improve accessibility of transcription download link #1187
|
||||
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
|
||||
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
|
||||
- ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
|
||||
- ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
|
||||
- ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) disable personal custom background while deleting #1183
|
||||
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
||||
- 🐛(frontend) fix device selection not applying during conference #1156
|
||||
|
||||
## [1.11.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) support celery with our Django backend #1124
|
||||
- ✨(helm) support ingress for custom background image #1124
|
||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
||||
- ✨(file-upload) disable by default & limit count by user #1141
|
||||
- ✨(frontend) custom background #1067
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||
- ♿️(frontend) improve chat toast a11y for screen readers #1109
|
||||
- ♿️(frontend) improve ui and aria labels for help article links #1108
|
||||
- 🌐(frontend) improve German translation #1125
|
||||
- 🔨(python-env) migrate meet main app to UV #1120
|
||||
- ♻️(backend) align Application model field with `is_active` convention #1133
|
||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
||||
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
||||
- 🔒️(backend) avoid information exposure through exception messages #1144
|
||||
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
|
||||
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
||||
- ♿️(frontend) add caption font and background color customization #1122
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
|
||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
||||
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
|
||||
|
||||
## [1.10.0] - 2026-03-05
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
||||
- 🦺(backend) strengthen API validation for recording options #1063
|
||||
- ⚡️(frontend) optimize few performance caveats #1073
|
||||
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
||||
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(migrations) use settings in migrations #1058
|
||||
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
||||
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
||||
- ♿(frontend) dynamic tab title when connected to meeting #1060
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add file upload feature #1030
|
||||
|
||||
## [1.9.0] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- 👷(docker) add arm64 platform support for image builds
|
||||
- ✨(summary) add localization support for transcription context text
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
|
||||
- 🔒️(frontend) uninstall curl from the frontend production image #987
|
||||
- 💄(frontend) add focus ring to reaction emoji buttons
|
||||
- ✨(frontend) introduce a shortcut settings tab #975
|
||||
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
||||
- 🌐(frontend) localize SR modifier labels #1010
|
||||
- ⬆️(backend) update python dependencies #1011
|
||||
- ♿️(frontend) fix focus ring on tab container components #1012
|
||||
- ♿️(frontend) upgrade join meeting modal accessibility #1027
|
||||
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
||||
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
||||
- ♿️(frontend) add skip link component for keyboard navigation #1019
|
||||
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) fix German language preference update #1021
|
||||
|
||||
## [1.8.0] - 2026-02-20
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(agents) uninstall pip from the agents image
|
||||
- 🔒️(summary) switch to Alpine base image
|
||||
- 🔒️(backend) uninstall pip in the production image
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
|
||||
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
|
||||
|
||||
## [1.7.0] - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) expose Windows app web link #976
|
||||
- ✨(frontend) support additional shortcuts to broaden accessibility
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
||||
- ♻️(backend) refactor external API token-related items #1006
|
||||
|
||||
## [1.6.0] - 2026-02-10
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) monitor throttling rate failure through sentry #964
|
||||
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) improve spinner reduced‑motion fallback #931
|
||||
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
||||
- 🥅(summary) catch file-related exceptions when handling recording #944
|
||||
- 📝(frontend) update legal terms #956
|
||||
- ⚡️(backend) enhance django admin's loading performance #954
|
||||
- 🌐(frontend) add missing DE translation for accessibility settings
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔐(backend) enforce object-level permission checks on room endpoint #959
|
||||
- 🔒️(backend) add application validation when consuming external JWT #963
|
||||
|
||||
## [1.5.0] - 2026-01-28
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
|
||||
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
|
||||
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
|
||||
- ♿️(frontend) add global screen reader announcer#922
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
|
||||
|
||||
## [1.4.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) add accessible back button in side panel #881
|
||||
- ♿️(frontend) improve participants toggle a11y label #880
|
||||
- ♿️(frontend) make carousel image decorative #871
|
||||
- ♿️(frontend) reactions are now vocalized and configurable #849
|
||||
- ♿️(frontend) improve background effect announcements #879
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒(backend) prevent automatic upgrade setuptools
|
||||
- ♿(frontend) improve contrast for selected options #863
|
||||
- ♿️(frontend) announce copy state in invite dialog #877
|
||||
- 📝(frontend) align close dialog label in rooms locale #878
|
||||
- 🩹(backend) use case-insensitive email matching in the external api #887
|
||||
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
|
||||
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
|
||||
|
||||
## [1.3.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) add dutch and german languages
|
||||
- 🔧(agents) make Silero VAD optional
|
||||
- 🚸(frontend) explain to a user they were ejected
|
||||
|
||||
### Changed
|
||||
|
||||
- 📈(frontend) track new recording's modes
|
||||
- ♿️(frontend) improve accessibility of the background and effects menu
|
||||
- ♿️(frontend) improve SR and focus for transcript and recording #810
|
||||
- 💄(frontend) adjust spacing in the recording side panels
|
||||
- 🚸(frontend) remove the default comma delimiter in humanized durations
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
@@ -13,14 +13,28 @@ RUN apk update && \
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /builder
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
# Disable Python downloads, because we want to use the system interpreter
|
||||
# across both images. If using a managed Python version, it needs to be
|
||||
# copied from the build image into the final image;
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
# install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY src/backend /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
@@ -30,7 +44,7 @@ COPY ./src/mail /mail/app
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
@@ -39,19 +53,20 @@ ARG MEET_STATIC_ROOT=/data/static
|
||||
|
||||
RUN apk add \
|
||||
pango \
|
||||
libmagic \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy Meet application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
python manage.py collectstatic --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
# final image
|
||||
@@ -68,6 +83,7 @@ RUN apk --no-cache add \
|
||||
gettext \
|
||||
libffi-dev \
|
||||
pango \
|
||||
libmagic \
|
||||
shared-mime-info
|
||||
|
||||
|
||||
@@ -79,14 +95,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy Meet application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
@@ -101,10 +120,9 @@ USER root:root
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall Meet and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y meet
|
||||
RUN pip install -e .[dev]
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
@@ -113,7 +131,7 @@ USER ${DOCKER_USER}
|
||||
# Target database host (e.g. database engine following docker compose services
|
||||
# name) & port
|
||||
ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
@@ -127,6 +145,9 @@ ARG MEET_STATIC_ROOT=/data/static
|
||||
RUN mkdir -p /usr/local/etc/gunicorn
|
||||
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
# ==============================================================================
|
||||
# VARIABLES
|
||||
|
||||
BOLD := \033[1m
|
||||
RESET := \033[0m
|
||||
GREEN := \033[1;32m
|
||||
ESC := $(shell printf '\033')
|
||||
BOLD := $(ESC)[1m
|
||||
RESET := $(ESC)[0m
|
||||
GREEN := $(ESC)[1;32m
|
||||
|
||||
|
||||
# -- Database
|
||||
@@ -72,7 +73,9 @@ create-env-files: \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
env.d/development/summary
|
||||
env.d/development/summary \
|
||||
env.d/development/kube-secret \
|
||||
env.d/development/multi_user_transcriber
|
||||
.PHONY: create-env-files
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
@@ -85,13 +88,15 @@ bootstrap: \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
mails-build \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend
|
||||
@$(MAKE) build-frontend
|
||||
@$(MAKE) build-agents
|
||||
.PHONY: build
|
||||
|
||||
build-backend: ## build the app-dev container
|
||||
@@ -103,6 +108,10 @@ build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend
|
||||
.PHONY: build-frontend
|
||||
|
||||
build-agents: ## build the multi-user-transcriber agent container
|
||||
@$(COMPOSE) build multi-user-transcriber
|
||||
.PHONY: build-agents
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
@@ -112,7 +121,8 @@ logs: ## display app-dev logs (follow mode)
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run-backend
|
||||
@@ -122,10 +132,24 @@ run-summary: ## start only the summary application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||
.PHONY: run-summary
|
||||
|
||||
run-agents: ## start the multi-user-transcriber agent
|
||||
@$(MAKE) run-agent-multi-user-transcriber
|
||||
@$(MAKE) run-agent-metadata-collector
|
||||
.PHONY: run-agents
|
||||
|
||||
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
||||
.PHONY: run-agent-multi-user-transcriber
|
||||
|
||||
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
|
||||
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
|
||||
.PHONY: run-agent-metadata-collector
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-agents
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
@@ -188,6 +212,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
@$(MAKE) test-summary
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@@ -200,6 +225,11 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
test-summary: ## run summary tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args:-${1}}
|
||||
.PHONY: test-summary
|
||||
|
||||
makemigrations: ## run django makemigrations for the Meet project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@@ -220,7 +250,7 @@ superuser: ## Create an admin superuser with password "admin"
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@@ -256,6 +286,12 @@ env.d/development/kc_postgresql:
|
||||
env.d/development/summary:
|
||||
cp -n env.d/development/summary.dist env.d/development/summary
|
||||
|
||||
env.d/development/kube-secret:
|
||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
||||
|
||||
env.d/development/multi_user_transcriber:
|
||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
@@ -343,21 +379,15 @@ frontend-i18n-generate: \
|
||||
|
||||
# -- K8S
|
||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up -f ./bin/Tiltfile
|
||||
build-k8s-cluster: \
|
||||
env.d/development/kube-secret \
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
||||
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
|
||||
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
web: bin/buildpack_start.sh
|
||||
postdeploy: python manage.py migrate
|
||||
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
### 🚀 Major roll out to all French public servants
|
||||
|
||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -86,7 +89,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||
|
||||
if DEV_ENV == 'dev-dinum':
|
||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||
@@ -34,10 +34,11 @@ docker_build(
|
||||
'localhost:5001/meet-frontend-dinum:latest',
|
||||
context='..',
|
||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
only=['./src/frontend', './src/addons', './docker', './.dockerignore'],
|
||||
target = 'frontend-production',
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
sync('../src/addons', '/home/addons'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend-dinum')
|
||||
@@ -70,7 +71,7 @@ docker_build(
|
||||
'localhost:5001/meet-agents:latest',
|
||||
context='../src/agents',
|
||||
dockerfile='../src/agents/Dockerfile',
|
||||
only=['.'],
|
||||
only=['.'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/agents', '/app'),
|
||||
@@ -95,11 +96,22 @@ docker_build(
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
load('ext://secret', 'secret_yaml_generic')
|
||||
k8s_yaml(secret_yaml_generic(
|
||||
name="secret-dev",
|
||||
from_env_file="../env.d/development/kube-secret"
|
||||
))
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-compile script"
|
||||
|
||||
# Cleanup
|
||||
rm -rf docker docs env.d gitlint
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-frontend script"
|
||||
|
||||
# Move the frontend build to the nginx root and clean up
|
||||
mkdir -p build/
|
||||
mv src/frontend/dist build/frontend-out
|
||||
|
||||
ASSETS_DIR=build/frontend-out/assets
|
||||
if [ -n "$CUSTOM_LOGO_URL" ]; then
|
||||
# Ensure https
|
||||
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
|
||||
|
||||
# Prevent SSRF
|
||||
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
|
||||
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
|
||||
|
||||
LOGO_FILE="${ASSETS_DIR}/logo.svg"
|
||||
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
|
||||
|
||||
# Actual download
|
||||
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
|
||||
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
|
||||
|
||||
# Validate filesize
|
||||
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
|
||||
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
|
||||
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
|
||||
|
||||
# Validate file type
|
||||
IS_SVG=false
|
||||
|
||||
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
|
||||
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
|
||||
|
||||
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
||||
|
||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
||||
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
||||
fi
|
||||
|
||||
mv src/backend/* ./
|
||||
mv deploy/paas/* ./
|
||||
|
||||
echo "3.13" > .python-version
|
||||
echo "." > requirements.txt
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start the Django backend server
|
||||
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
||||
|
||||
# Start the Nginx server
|
||||
bin/run &
|
||||
|
||||
# if the current shell is killed, also terminate all its children
|
||||
trap "pkill SIGTERM -P $$" SIGTERM
|
||||
|
||||
# wait for a single child to finish,
|
||||
wait -n
|
||||
# then kill all the other tasks
|
||||
pkill -P $$
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
app-summary-dev \
|
||||
python -m pytest "$@"
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||
/usr/bin/mc admin service restart meet --wait --json &&
|
||||
sleep 15 &&
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
@@ -80,11 +80,11 @@ services:
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
- createwebhook
|
||||
@@ -106,6 +106,7 @@ services:
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
@@ -148,6 +149,7 @@ services:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
@@ -244,6 +246,29 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
- LIVEKIT_API_KEY=devkey
|
||||
- LIVEKIT_API_SECRET=secret
|
||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
||||
- AWS_S3_ACCESS_KEY_ID=meet
|
||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./src/agents
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
@@ -305,6 +330,14 @@ services:
|
||||
- action: rebuild
|
||||
path: ./src/summary
|
||||
|
||||
multi-user-transcriber:
|
||||
build:
|
||||
context: ./src/agents
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# ERB templated nginx configuration
|
||||
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
|
||||
|
||||
upstream backend_server {
|
||||
server localhost:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
listen <%= ENV["PORT"] %>;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
|
||||
root /app/build/frontend-out;
|
||||
|
||||
# Django rest framework
|
||||
location ^~ /api/ {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://backend_server;
|
||||
}
|
||||
|
||||
# Django admin
|
||||
location ^~ /admin/ {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://backend_server;
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Add no-cache headers
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
error_page 404 =200 /index.html;
|
||||
}
|
||||
@@ -38,16 +38,32 @@ COPY ./docker/dinum-frontend/assets/ \
|
||||
COPY ./docker/dinum-frontend/fonts/ \
|
||||
./dist/assets/fonts/
|
||||
|
||||
# ---- Addons builder image ----
|
||||
FROM node:20-alpine AS addons-builder
|
||||
|
||||
WORKDIR /home/addons/outlook
|
||||
|
||||
COPY ./src/addons/outlook/package.json ./package.json
|
||||
COPY ./src/addons/outlook/package-lock.json ./package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY ./src/addons/outlook/ .
|
||||
|
||||
RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 \
|
||||
libcrypto3 \
|
||||
libxml2>=2.12.7-r2 \
|
||||
libxslt>=1.1.39-r2 \
|
||||
libexpat>=2.7.2-r0 \
|
||||
libpng>=1.6.53-r0
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
&& apk del curl
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -59,7 +75,11 @@ COPY --from=meet-builder \
|
||||
/home/frontend/dist \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||
COPY --from=addons-builder \
|
||||
/home/addons/outlook/dist \
|
||||
/usr/share/nginx/html/addons/outlook
|
||||
|
||||
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location = /.well-known/windows-app-web-link {
|
||||
default_type application/json;
|
||||
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
|
||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
||||
}
|
||||
|
||||
# Manifest — fetched, never iframed
|
||||
location = /addons/outlook/manifest.xml {
|
||||
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
|
||||
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'";
|
||||
}
|
||||
|
||||
location = /addons/outlook/assets/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable" always;
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Vary "Origin" always;
|
||||
}
|
||||
|
||||
location = /addons/outlook/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ ^/addons/outlook(/.*)?$ {
|
||||
alias /usr/share/nginx/html/addons/outlook$1;
|
||||
error_page 404 =200 /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires 0 always;
|
||||
|
||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
||||
|
||||
set $nonce $request_id;
|
||||
|
||||
set $csp "upgrade-insecure-requests; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
||||
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
|
||||
set $csp "${csp}frame-src 'none'; ";
|
||||
set $csp "${csp}object-src 'none'; ";
|
||||
set $csp "${csp}base-uri 'none'; ";
|
||||
|
||||
add_header Content-Security-Policy $csp;
|
||||
|
||||
sub_filter 'NONCE_PLACEHOLDER' $nonce;
|
||||
sub_filter_once off;
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Add no-cache headers
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
error_page 404 =200 /index.html;
|
||||
}
|
||||
@@ -4,10 +4,47 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
auth_request /media-auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
# Pass specific headers from the auth response
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/meet-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
# To use with ds_proxy
|
||||
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
||||
# proxy_set_header Host ds-proxy:4444;
|
||||
add_header Content-Disposition "attachment";
|
||||
}
|
||||
|
||||
location /media-auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
upstream meet_backend {
|
||||
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
upstream meet_frontend {
|
||||
server ${FRONTEND_HOST}:8080 fail_timeout=0;
|
||||
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM livekit/livekit-server:v1.9.0
|
||||
FROM livekit/livekit-server:v1.9.4
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
@@ -3,3 +3,8 @@ redis:
|
||||
address: redis:6379
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
volumes:
|
||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
- ./data/databases/backend:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
@@ -20,8 +20,8 @@ services:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
- env.d/common
|
||||
- env.d/backend
|
||||
- env.d/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "manage.py", "check"]
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
- /docker-entrypoint.sh
|
||||
command: ["nginx", "-g", "daemon off;"]
|
||||
env_file:
|
||||
- .env
|
||||
- env.d/common
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
### Step 1: Prepare your working environment:
|
||||
|
||||
```bash
|
||||
mkdir keycloak/env.d && cd keycloak
|
||||
mkdir -p keycloak/env.d && cd keycloak
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
|
||||
@@ -61,11 +61,10 @@ services:
|
||||
`docker compose up -d`
|
||||
```
|
||||
|
||||
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
|
||||
> [!CAUTION]
|
||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||
```
|
||||
|
||||
## Creating an OIDC Client for Meet Application
|
||||
|
||||
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
3. Enter the name of the realm - `meet`.
|
||||
4. Click "Create".
|
||||
|
||||
#### Step 2: Create a New Client
|
||||
### Step 2: Create a New Client
|
||||
|
||||
1. Navigate to the "Clients" tab.
|
||||
2. Click on the "Create client" button.
|
||||
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||
1. Click "Save".
|
||||
|
||||
#### Step 3: Get Client Credentials
|
||||
### Step 3: Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
|
||||
@@ -68,13 +68,13 @@ backend:
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
|
||||
@@ -9,6 +9,10 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
|
||||
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
|
||||
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
||||
|
||||
## Scalingo
|
||||
|
||||
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
|
||||
|
||||
## Other ways to install La Suite Meet
|
||||
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
|
||||
|
||||
## Step 2: Configuration
|
||||
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
||||
|
||||
In this example, we assume the following services:
|
||||
|
||||
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
|
||||
|
||||
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
||||
|
||||
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
||||
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
||||
|
||||
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
|
||||
|
||||
> [!NOTE]
|
||||
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
|
||||
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports.
|
||||
|
||||
### Meet
|
||||
|
||||
@@ -129,7 +129,7 @@ The following ports will need to be opened:
|
||||
- 7881/tcp - WebRTC ICE over TCP
|
||||
- 7882/udp - for WebRTC multiplexing over UDP
|
||||
|
||||
If you are using ufw, enter the follwoing:
|
||||
If you are using ufw, enter the following:
|
||||
```
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
|
||||
# external: true
|
||||
```
|
||||
|
||||
#### Caddy Reverse Proxy
|
||||
Expose the Frontend port to the host
|
||||
```yaml
|
||||
frontend:
|
||||
…
|
||||
ports:
|
||||
- "8086:8086"
|
||||
```
|
||||
|
||||
## Step 5: Start Meet
|
||||
|
||||
You are ready to start your Meet application !
|
||||
@@ -198,7 +207,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
|
||||
|
||||
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
|
||||
|
||||
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||
|
||||
## How to upgrade your Meet application
|
||||
|
||||
|
||||
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
|
||||
$ kubectl create namespace meet
|
||||
```
|
||||
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
You can find these values in **examples/helm/keycloak.values.yaml**
|
||||
|
||||
### Find livekit server connexion values
|
||||
|
||||
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
|
||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 26m
|
||||
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
|
||||
```
|
||||
$ helm repo add livekit https://helm.livekit.io
|
||||
$ helm repo update
|
||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 30m
|
||||
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 45m
|
||||
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
|
||||
```
|
||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Deployment on Scalingo
|
||||
|
||||
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
|
||||
|
||||
## Overview
|
||||
|
||||
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Scalingo account
|
||||
- Scalingo CLI installed (optional but recommended)
|
||||
- A PostgreSQL database addon
|
||||
- A Redis addon (for caching and sessions)
|
||||
|
||||
## Step 1: Create Your App
|
||||
|
||||
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
|
||||
|
||||
## Step 2: Provision Addons
|
||||
|
||||
Add the required PostgreSQL and Redis services.
|
||||
|
||||
This will set the following environment variables automatically:
|
||||
- `SCALINGO_POSTGRESQL_URL` - Database connection string
|
||||
- `SCALINGO_REDIS_URL` - Redis connection string
|
||||
|
||||
## Step 3: Configure Environment Variables
|
||||
|
||||
Set the following environment variables in your Scalingo app:
|
||||
|
||||
### Buildpack Configuration
|
||||
|
||||
```bash
|
||||
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
|
||||
scalingo env-set LASUITE_APP_NAME="meet"
|
||||
scalingo env-set LASUITE_BACKEND_DIR="."
|
||||
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
||||
scalingo env-set LASUITE_NGINX_DIR="."
|
||||
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
|
||||
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
|
||||
```
|
||||
|
||||
### Database and Cache
|
||||
|
||||
```bash
|
||||
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
|
||||
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
|
||||
```
|
||||
|
||||
### Django Settings
|
||||
|
||||
```bash
|
||||
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
|
||||
scalingo env-set DJANGO_CONFIGURATION="Production"
|
||||
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
|
||||
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
|
||||
```
|
||||
|
||||
### OIDC Authentication
|
||||
|
||||
Configure your OIDC provider (e.g., Keycloak, Authentik):
|
||||
|
||||
```bash
|
||||
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
|
||||
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
|
||||
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
|
||||
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
|
||||
```
|
||||
|
||||
### LiveKit Configuration
|
||||
|
||||
Meet requires a LiveKit server for video conferencing:
|
||||
|
||||
```bash
|
||||
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
|
||||
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
|
||||
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
|
||||
```
|
||||
|
||||
### Email Configuration (Optional)
|
||||
|
||||
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
|
||||
|
||||
```bash
|
||||
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
|
||||
scalingo env-set DJANGO_EMAIL_PORT="587"
|
||||
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
|
||||
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
|
||||
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
|
||||
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
|
||||
```
|
||||
|
||||
## Step 4: Deploy
|
||||
|
||||
Deploy your application:
|
||||
|
||||
```bash
|
||||
git push scalingo main
|
||||
```
|
||||
|
||||
The Procfile will automatically:
|
||||
1. Build the frontend (Vite)
|
||||
2. Build the backend (Django)
|
||||
3. Run the post-compile script (cleanup)
|
||||
4. Run the post-frontend script (move assets and prepare for deployment)
|
||||
5. Start Nginx and Gunicorn
|
||||
6. Run django migrations
|
||||
|
||||
## Step 5: Create superuser
|
||||
|
||||
After the first deployment, create an admin user:
|
||||
|
||||
```bash
|
||||
scalingo run python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Custom Domain (Optional)
|
||||
|
||||
To use a custom domain:
|
||||
|
||||
1. Add the domain in Scalingo dashboard
|
||||
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
|
||||
3. Configure your DNS to point to Scalingo
|
||||
|
||||
```bash
|
||||
scalingo domains-add meet.yourdomain.com
|
||||
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
|
||||
```
|
||||
|
||||
## Custom Logo (Optional)
|
||||
|
||||
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
|
||||
|
||||
```bash
|
||||
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
scalingo logs --tail
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Build fails**: Check that all required environment variables are set
|
||||
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
|
||||
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
|
||||
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Open a console
|
||||
scalingo run bash
|
||||
|
||||
# Restart the app
|
||||
scalingo restart
|
||||
|
||||
# Scale containers
|
||||
scalingo scale web:2
|
||||
|
||||
# One-off command
|
||||
scalingo run python manage.py shell
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
On Scalingo, the application runs as follows:
|
||||
|
||||
1. **Build Phase**: The buildpack compiles both frontend and backend
|
||||
2. **Runtime**:
|
||||
- Nginx serves static files and proxies to the backend
|
||||
- Gunicorn runs the Django WSGI application
|
||||
- Both processes are managed by the `bin/buildpack_start.sh` script
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Scalingo Documentation](https://doc.scalingo.com/)
|
||||
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
|
||||
- [Meet Environment Variables](../../src/helm/meet/README.md)
|
||||
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
|
||||
@@ -190,6 +190,7 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
|
||||
@@ -113,6 +113,7 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
|
||||
@@ -23,9 +23,12 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
@@ -68,6 +71,9 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
WHISPERX_BASE_URL=https://configure-your-url.com
|
||||
WHISPERX_API_KEY=<key>
|
||||
LLM_BASE_URL=https://configure-your-url.com
|
||||
LLM_API_KEY=<key>
|
||||
@@ -0,0 +1,9 @@
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
STT_PROVIDER=kyutai
|
||||
ENABLE_SILERO_VAD=False
|
||||
|
||||
KYUTAI_STT_BASE_URL=
|
||||
KYUTAI_API_KEY=
|
||||
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="https://${MEET_HOST}"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Gitlint extra rule to validate that the message title is of the form
|
||||
"<gitmoji>(<scope>) <subject>"
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"minimumReleaseAge": "7 days",
|
||||
"internalChecksFilter": "strict"
|
||||
},
|
||||
{
|
||||
"groupName": "python dependencies",
|
||||
"matchManagers": ["setup-cfg", "pep621"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"minimumReleaseAge": "7 days"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
@@ -15,6 +30,18 @@
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed brevo versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["brevo-python"],
|
||||
"allowedVersions": "<3.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": [
|
||||
"office-addins"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:office-addins/recommended"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 396 B |
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": false
|
||||
}
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||
<Version>0.0.1.0</Version>
|
||||
<ProviderName>__APP_NAME__</ProviderName>
|
||||
<DefaultLocale>fr-FR</DefaultLocale>
|
||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||
<Description DefaultValue="Ajoutez facilement un lien de réunion __APP_NAME__ à vos emails et événements Outlook."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
|
||||
<SupportUrl DefaultValue="https://lasuite.crisp.help/fr/category/visio-15sakkg/"/>
|
||||
<AppDomains>
|
||||
<AppDomain>https://localhost:3000/</AppDomain>
|
||||
</AppDomains>
|
||||
<Hosts>
|
||||
<Host Name="Mailbox"/>
|
||||
</Hosts>
|
||||
<Requirements>
|
||||
<Sets>
|
||||
<Set Name="Mailbox" MinVersion="1.1"/>
|
||||
</Sets>
|
||||
</Requirements>
|
||||
<FormSettings>
|
||||
<Form xsi:type="ItemRead">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
<RequestedHeight>250</RequestedHeight>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
<Form xsi:type="ItemEdit">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
</FormSettings>
|
||||
<Permissions>ReadWriteItem</Permissions>
|
||||
<Rule xsi:type="RuleCollection" Mode="Or">
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
|
||||
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
|
||||
</Rule>
|
||||
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||
<Requirements>
|
||||
<bt:Sets DefaultMinVersion="1.3">
|
||||
<bt:Set Name="Mailbox"/>
|
||||
</bt:Sets>
|
||||
</Requirements>
|
||||
<Hosts>
|
||||
<Host xsi:type="MailHost">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Commands.Url"/>
|
||||
|
||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgReadGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="apptComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||
<Label resid="OpenSettings.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="OpenSettings.Label"/>
|
||||
<Description resid="OpenSettings.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/assets/settings-16.png"/>
|
||||
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/assets/settings-32.png"/>
|
||||
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/assets/settings-80.png"/>
|
||||
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/assets/add-16.png"/>
|
||||
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/assets/add-32.png"/>
|
||||
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/assets/add-80.png"/>
|
||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
|
||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
|
||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
|
||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "office-addin-taskpane-js",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/suitenumerique/meet.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"config": {
|
||||
"app_to_debug": "outlook",
|
||||
"app_type_to_debug": "desktop",
|
||||
"dev_server_port": 3000
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack --mode development",
|
||||
"dev-server": "webpack serve --mode development",
|
||||
"lint": "office-addin-lint check",
|
||||
"lint:fix": "office-addin-lint fix",
|
||||
"prettier": "office-addin-lint prettier",
|
||||
"signin": "office-addin-dev-settings m365-account login",
|
||||
"signout": "office-addin-dev-settings m365-account logout",
|
||||
"start": "office-addin-debugging start manifest.xml",
|
||||
"stop": "office-addin-debugging stop manifest.xml",
|
||||
"validate": "office-addin-manifest validate manifest.xml",
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.36.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^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,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title data-app-name></title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
/* global Office */
|
||||
const { createRoom, initSession } = require("../common/api");
|
||||
const { startPolling } = require("../common/polling");
|
||||
const { saveSession, loadSession } = require("../common/session");
|
||||
const { openTransitDialog } = require("../common/transitDialog");
|
||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
|
||||
Office.onReady(function (info) {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
}
|
||||
});
|
||||
|
||||
function notify(message) {
|
||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
||||
message,
|
||||
persistent: false,
|
||||
icon: "Icon.16x16",
|
||||
});
|
||||
}
|
||||
|
||||
function insertMeetingLink(event, session) {
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de lecture : ${getResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const newBody = getResult.value + message;
|
||||
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur d'insertion : ${setResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
notify("Lien de réunion inséré !");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de localisation : ${locationResult.error.message}`);
|
||||
} else {
|
||||
notify("Lien de réunion inséré !");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(`Erreur : ${err.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
function connect(event) {
|
||||
initSession()
|
||||
.then((data) => {
|
||||
const stopPolling = startPolling(data.csrf_token, {
|
||||
onSuccess: (sessionData) => {
|
||||
saveSession(sessionData).then(() => {
|
||||
insertMeetingLink(event, sessionData);
|
||||
});
|
||||
},
|
||||
onTimeout: () => {
|
||||
notify("Connexion expirée, veuillez réessayer.");
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
notify("Une erreur est survenue, veuillez ré-essayer");
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
openTransitDialog(data.transit_token, {
|
||||
onCancel: () => {
|
||||
stopPolling();
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
stopPolling();
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(`Erreur : ${err.message}`);
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
function generateMeetingLink(event) {
|
||||
const session = loadSession();
|
||||
if (session?.access_token) {
|
||||
insertMeetingLink(event, session);
|
||||
} else {
|
||||
connect(event);
|
||||
}
|
||||
}
|
||||
|
||||
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLink);
|
||||
Office.actions.associate("generateMeetingLinkFromMail", generateMeetingLink);
|
||||
@@ -0,0 +1,82 @@
|
||||
const { URLS } = require("./urls");
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
|
||||
.map((cookie) => cookie.split("=")[1])
|
||||
.pop();
|
||||
}
|
||||
|
||||
function authHeaders(session) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds headers for CSRF-protected requests.
|
||||
*
|
||||
* Two CSRF flows coexist in this addon:
|
||||
*
|
||||
* 1. Cookie-based (Django default): used by `exchange`, called from the
|
||||
* OAuth success page in a normal browser context. Django's CSRF
|
||||
* middleware has already set the `csrftoken` cookie via the auth
|
||||
* redirect, so we read it from `document.cookie` and echo it back
|
||||
* as `X-CSRFToken`. The middleware verifies the header matches the
|
||||
* cookie. No `csrfToken` argument needed — `getCsrfToken()` handles it.
|
||||
*
|
||||
* 2. Body-passed token: used by `poll`, called from the Office dialog /
|
||||
* taskpane iframe. Cookie access inside Office iframes is unreliable
|
||||
* across Outlook clients, so we can't depend on `document.cookie`
|
||||
* being populated. Instead, `init` returns the CSRF token in its JSON
|
||||
* response body, and callers pass it explicitly to subsequent calls.
|
||||
* The token still travels as `X-CSRFToken` — only its source differs.
|
||||
*
|
||||
* The `csrfToken` parameter takes precedence when provided; falls back
|
||||
* to the cookie when omitted.
|
||||
*/
|
||||
function csrfHeaders(csrfToken) {
|
||||
const token = csrfToken || getCsrfToken();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { "X-CSRFToken": token }),
|
||||
};
|
||||
}
|
||||
|
||||
async function request(path, { session, csrf, csrfToken, ...opts } = {}) {
|
||||
const headers = {
|
||||
...(session && authHeaders(session)),
|
||||
...(csrf && csrfHeaders(csrfToken)),
|
||||
...opts.headers,
|
||||
};
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: csrf ? "include" : opts.credentials,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initSession: () => request(URLS.init, { method: "POST" }),
|
||||
pollSession: (csrfToken) =>
|
||||
request(URLS.poll, {
|
||||
method: "POST",
|
||||
csrf: true,
|
||||
csrfToken,
|
||||
}),
|
||||
exchangeSession: (transitToken) =>
|
||||
request(URLS.exchange, {
|
||||
method: "POST",
|
||||
csrf: true,
|
||||
body: JSON.stringify({ transit_token: transitToken }),
|
||||
}),
|
||||
createRoom: (session) =>
|
||||
request(URLS.rooms, {
|
||||
method: "POST",
|
||||
session,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const { APP_NAME } = require("./index");
|
||||
|
||||
function isOfficeReady() {
|
||||
return typeof Office !== "undefined" && Office?.context?.roamingSettings != null;
|
||||
}
|
||||
|
||||
function applyAppName() {
|
||||
document.querySelectorAll("[data-app-name]").forEach((el) => {
|
||||
el.textContent = APP_NAME;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isOfficeReady,
|
||||
applyAppName,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
|
||||
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
|
||||
|
||||
module.exports = {
|
||||
BASE_URL,
|
||||
APP_NAME,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
const { APP_NAME } = require("./index");
|
||||
|
||||
function _formatPin(pin) {
|
||||
if (!pin) return "";
|
||||
const clean = String(pin).replace(/\s+/g, "");
|
||||
if (!clean) return "";
|
||||
if (/^\d{10}$/.test(clean)) {
|
||||
return clean.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#";
|
||||
}
|
||||
return clean + "#";
|
||||
}
|
||||
|
||||
// todo - support international format
|
||||
function _formatPhone(phone) {
|
||||
if (!phone) return "";
|
||||
const clean = String(phone).replace(/\s+/g, "");
|
||||
if (/^\+33\d{9}$/.test(clean)) {
|
||||
return clean.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
// todo - escape html / link
|
||||
function buildMeetingMessage(data) {
|
||||
if (!data?.url) {
|
||||
throw new Error("buildMeetingMessage: missing url in data");
|
||||
}
|
||||
|
||||
const url = data.url;
|
||||
const phone = _formatPhone(data.telephony?.phone_number);
|
||||
const pin = _formatPin(data.telephony?.pin_code);
|
||||
|
||||
const telephonyBlock =
|
||||
phone && pin
|
||||
? `
|
||||
|
||||
Ou appelez (audio uniquement)
|
||||
(FR) ${phone}
|
||||
Code : ${pin}`
|
||||
: "";
|
||||
|
||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
||||
────────────────────────────────────────
|
||||
Rejoindre la réunion ${APP_NAME}
|
||||
|
||||
<a href="${url}">${url}</a>${telephonyBlock}
|
||||
────────────────────────────────────────</pre>`;
|
||||
|
||||
return { url, message };
|
||||
}
|
||||
|
||||
module.exports = { buildMeetingMessage };
|
||||
@@ -0,0 +1,47 @@
|
||||
const { pollSession } = require("./api");
|
||||
|
||||
const POLLING_INTERVAL_MS = 1000;
|
||||
const POLLING_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const POLLING_MAX_ATTEMPTS = POLLING_TIMEOUT_MS / POLLING_INTERVAL_MS;
|
||||
|
||||
function isPollAuthenticated(sessionData) {
|
||||
return sessionData.state === "authenticated" && sessionData.access_token;
|
||||
}
|
||||
|
||||
function startPolling(csrfToken, { onSuccess, onTimeout, onError }) {
|
||||
let pollCount = 0;
|
||||
let timeoutId = null;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = () => {
|
||||
if (pollCount++ >= POLLING_MAX_ATTEMPTS) {
|
||||
onTimeout?.();
|
||||
return;
|
||||
}
|
||||
|
||||
pollSession(csrfToken)
|
||||
.then((sessionData) => {
|
||||
if (cancelled) return;
|
||||
if (isPollAuthenticated(sessionData)) {
|
||||
onSuccess?.(sessionData);
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(poll, POLLING_INTERVAL_MS);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
onError?.(err);
|
||||
});
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPolling,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
const { isOfficeReady } = require("./helpers");
|
||||
|
||||
const SESSION_KEY = "meetSession";
|
||||
|
||||
// DEV NOTE:
|
||||
// Office.context.roamingSettings persists data in the user's mailbox and
|
||||
// synchronizes it via Exchange across all Outlook clients (desktop, web, mobile)
|
||||
// where the user signs in. This means anything stored here (including tokens)
|
||||
// leaves the local device boundary and is replicated across environments.
|
||||
//
|
||||
// Microsoft guidance explicitly advises NOT storing secrets (e.g., OAuth access
|
||||
// tokens, refresh tokens, or other sensitive credentials) in roamingSettings,
|
||||
// as it is not a secure storage mechanism and lacks OS-level protections.
|
||||
//
|
||||
// That said, for the current alpha version we accept this trade-off for simplicity,
|
||||
// with the expectation that a more secure approach (e.g., in-memory tokens) will replace this.
|
||||
function saveSession(data) {
|
||||
if (!isOfficeReady()) {
|
||||
return Promise.reject(new Error("Office not ready"));
|
||||
}
|
||||
|
||||
if (!data || !data.access_token) {
|
||||
return Promise.reject(new Error("Missing access_token"));
|
||||
}
|
||||
|
||||
const expiresInSeconds = Number(data.expires_in);
|
||||
const expiresAt =
|
||||
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
||||
? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
...data,
|
||||
expiresAt,
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const rs = Office.context.roamingSettings;
|
||||
rs.set(SESSION_KEY, payload);
|
||||
rs.saveAsync((result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(result.error?.message || "saveAsync failed"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
if (!isOfficeReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let session = null;
|
||||
try {
|
||||
const stored = Office.context.roamingSettings.get(SESSION_KEY);
|
||||
if (stored) session = JSON.parse(stored);
|
||||
} catch (e) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Fail closed if expiry is missing — backend is expected to send expires_in.
|
||||
if (!session.expiresAt) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresTs = Date.parse(session.expiresAt);
|
||||
if (!Number.isFinite(expiresTs) || Date.now() >= expiresTs) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
if (!isOfficeReady()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const rs = Office.context.roamingSettings;
|
||||
rs.remove(SESSION_KEY);
|
||||
rs.saveAsync((result) => {
|
||||
resolve();
|
||||
});
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
const { URLS } = require("./urls");
|
||||
|
||||
const DIALOG_SIGNALS = {
|
||||
ready: "ready",
|
||||
done: "done",
|
||||
};
|
||||
|
||||
const DIALOG_HEIGHT = 60;
|
||||
const DIALOG_WIDTH = 50;
|
||||
|
||||
function openTransitDialog(transitToken, { onCancel, onError }) {
|
||||
Office.context.ui.displayDialogAsync(
|
||||
URLS.transitDialog,
|
||||
{ height: DIALOG_HEIGHT, width: DIALOG_WIDTH, displayInIframe: false },
|
||||
(asyncResult) => {
|
||||
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
|
||||
onError?.(asyncResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = asyncResult.value;
|
||||
|
||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg) => {
|
||||
if (arg.message === DIALOG_SIGNALS.ready) {
|
||||
dialog.messageChild(transitToken);
|
||||
return;
|
||||
}
|
||||
if (arg.message === DIALOG_SIGNALS.done) {
|
||||
return;
|
||||
}
|
||||
onCancel?.();
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openTransitDialog,
|
||||
DIALOG_SIGNALS,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const TRANSIT_TOKEN_KEY = "transitToken";
|
||||
|
||||
function save(token) {
|
||||
sessionStorage.setItem(TRANSIT_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
function consume() {
|
||||
try {
|
||||
const token = sessionStorage.getItem(TRANSIT_TOKEN_KEY);
|
||||
sessionStorage.removeItem(TRANSIT_TOKEN_KEY);
|
||||
return token;
|
||||
} catch (err) {
|
||||
console.error("Failed to read transit token:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { save, consume };
|
||||
@@ -0,0 +1,15 @@
|
||||
const { BASE_URL } = require("./index");
|
||||
|
||||
const ADDONS_BASE_URL = `${BASE_URL}/api/v1.0/addons/sessions`;
|
||||
|
||||
const URLS = {
|
||||
authenticate: `${BASE_URL}/api/v1.0/authenticate/`,
|
||||
successPage: `${BASE_URL}/addons/outlook/success.html`,
|
||||
transitDialog: `${BASE_URL}/addons/outlook/transit.html`,
|
||||
init: `${ADDONS_BASE_URL}/init/`,
|
||||
poll: `${ADDONS_BASE_URL}/poll/`,
|
||||
exchange: `${ADDONS_BASE_URL}/exchange/`,
|
||||
rooms: `${BASE_URL}/external-api/v1.0/rooms/`,
|
||||
};
|
||||
|
||||
module.exports = { URLS };
|
||||
@@ -0,0 +1,81 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#sideload-msg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.spinner-svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
/* Background arc (light gray ring) */
|
||||
.spinner-track {
|
||||
stroke: #E5E7EB; /* primary.100 equivalent */
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Foreground rotating arc */
|
||||
.spinner-arc {
|
||||
stroke: #000091; /* primary.800 equivalent */
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
/* circumference = 2 * PI * r where r = 11 -> ~69.115 */
|
||||
/* show 30% -> dashoffset = c - 0.3 * c = ~48.38 */
|
||||
stroke-dasharray: 69.115 69.115;
|
||||
stroke-dashoffset: 48.38;
|
||||
transform-origin: center;
|
||||
animation: spinner-rotate 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hourglass fallback for reduced motion */
|
||||
.spinner-fallback {
|
||||
display: none;
|
||||
color: #000091;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner-svg {
|
||||
display: none;
|
||||
}
|
||||
.spinner-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<svg class="spinner-svg"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Background track -->
|
||||
<circle class="spinner-track" cx="14" cy="14" r="11"
|
||||
/>
|
||||
<!-- Rotating arc -->
|
||||
<circle class="spinner-arc" cx="14" cy="14" r="11"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
|
||||
<span class="spinner-fallback" aria-hidden="true">
|
||||
<svg width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="display: block; transform: translateY(1px);"
|
||||
>
|
||||
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { exchangeSession } = require("../common/api");
|
||||
const { consume } = require("../common/transitToken");
|
||||
|
||||
applyAppName();
|
||||
|
||||
const transitToken = consume();
|
||||
|
||||
if (!transitToken) {
|
||||
console.error("Transit token not found in sessionStorage");
|
||||
window.close();
|
||||
} else {
|
||||
exchangeSession(transitToken)
|
||||
.catch((e) => {
|
||||
console.error(`Error occured: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="taskpane.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
|
||||
<div id="app-body">
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="view-loading">
|
||||
<p class="intro-text">Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated -->
|
||||
<div id="view-unauth" style="display:none;">
|
||||
<p class="intro-text">
|
||||
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
|
||||
</p>
|
||||
<hr class="divider" />
|
||||
<button class="proconnect-button" id="btn-connect">
|
||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
||||
</button>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.proconnect.gouv.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
||||
>
|
||||
Qu’est-ce que ProConnect ?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated -->
|
||||
<div id="view-auth" style="display:none;">
|
||||
<div id="btn-container">
|
||||
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
|
||||
<button id="btn-disconnect">Se déconnecter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="version-tag">
|
||||
<span class="version-badge">alpha</span>
|
||||
<span class="version-number">0.0.1</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
const { APP_NAME } = require("../common");
|
||||
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { initSession, createRoom } = require("../common/api");
|
||||
const { startPolling } = require("../common/polling");
|
||||
const { openTransitDialog } = require("../common/transitDialog");
|
||||
const { loadSession, saveSession, clearSession } = require("../common/session");
|
||||
|
||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||
|
||||
// todo - support loading view while polling
|
||||
// todo - support error view
|
||||
function showView(name) {
|
||||
document.getElementById("view-loading").style.display = "none";
|
||||
document.getElementById("view-unauth").style.display = "none";
|
||||
document.getElementById("view-auth").style.display = "none";
|
||||
document.getElementById(`view-${name}`).style.display = "block";
|
||||
}
|
||||
|
||||
function connect() {
|
||||
initSession()
|
||||
.then((data) => {
|
||||
const stopPolling = startPolling(data.csrf_token, {
|
||||
onSuccess: (sessionData) => {
|
||||
saveSession(sessionData).then(() => showView("auth"));
|
||||
},
|
||||
onTimeout: () => {
|
||||
showView("unauth");
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
},
|
||||
});
|
||||
openTransitDialog(data.transit_token, {
|
||||
onCancel: () => stopPolling(),
|
||||
onError: (err) => {
|
||||
stopPolling();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
clearSession().finally(() => showView("unauth"));
|
||||
}
|
||||
|
||||
function _setButtonLoading() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Génération...";
|
||||
}
|
||||
|
||||
function _setButtonIdle() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = false;
|
||||
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
|
||||
}
|
||||
|
||||
function generateMeetingLink() {
|
||||
const session = loadSession();
|
||||
if (!session?.access_token) {
|
||||
console.error("Session introuvable. Veuillez vous reconnecter.");
|
||||
showView("unauth");
|
||||
return;
|
||||
}
|
||||
|
||||
_setButtonLoading();
|
||||
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(getResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
item.body.setAsync(
|
||||
getResult.value + message,
|
||||
{ coercionType: Office.CoercionType.Html },
|
||||
(setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(setResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── If calendar event, also set location ──────────────
|
||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||
item.location.setAsync(url, () => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
_setButtonIdle();
|
||||
});
|
||||
}
|
||||
|
||||
Office.onReady((info) => {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
document.getElementById("sideload-msg").style.display = "none";
|
||||
document.getElementById("app-body").style.display = "flex";
|
||||
document.getElementById("btn-connect").onclick = connect;
|
||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
||||
|
||||
const session = loadSession();
|
||||
if (session?.state === "authenticated" && session?.access_token) {
|
||||
showView("auth");
|
||||
} else {
|
||||
showView("unauth");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div
|
||||
class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<svg
|
||||
class="spinner-svg"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Background track -->
|
||||
<circle class="spinner-track" cx="14" cy="14" r="11"
|
||||
/>
|
||||
<!-- Rotating arc -->
|
||||
<circle class="spinner-arc" cx="14" cy="14" r="11"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
|
||||
<span class="spinner-fallback" aria-hidden="true">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="display: block; transform: translateY(1px);"
|
||||
>
|
||||
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { URLS } = require("../common/urls");
|
||||
const { save } = require("../common/transitToken");
|
||||
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
||||
|
||||
// Initiate the authentication flow, then return to the success page
|
||||
function getAuthenticateUrl() {
|
||||
const url = new URL(URLS.authenticate);
|
||||
url.searchParams.set("returnTo", URLS.successPage);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
Office.onReady(function (info) {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
}
|
||||
|
||||
Office.context.ui.addHandlerAsync(
|
||||
Office.EventType.DialogParentMessageReceived,
|
||||
function (arg) {
|
||||
const transitToken = arg.message;
|
||||
|
||||
if (typeof transitToken !== "string" || transitToken.trim() === "") {
|
||||
console.error("Invalid transit token received from parent dialog.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Runs inside the dialog window.
|
||||
// Flow:
|
||||
// transit.html saves token → navigates to /authenticate → OAuth redirect →
|
||||
// success.html. sessionStorage survives because it's per-window-per-origin
|
||||
// and the dialog window persists across same-origin navigations.
|
||||
// Fragile: if the IdP opens the redirect in a new tab/window, this breaks
|
||||
// silently.
|
||||
// An alternative could be to pass the token via the OAuth `state` param
|
||||
// and read it back from the redirect URL.
|
||||
try {
|
||||
save(transitToken);
|
||||
Office.context.ui.messageParent(DIALOG_SIGNALS.done);
|
||||
window.location.href = getAuthenticateUrl();
|
||||
} catch (err) {
|
||||
console.error("Failed to store transit token:", err);
|
||||
}
|
||||
},
|
||||
function (result) {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
console.error("Failed to register DialogParentMessageReceived handler.", result.error);
|
||||
return;
|
||||
}
|
||||
Office.context.ui.messageParent(DIALOG_SIGNALS.ready);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const devCerts = require("office-addin-dev-certs");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
||||
|
||||
async function getHttpsOptions() {
|
||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||
}
|
||||
|
||||
module.exports = async (env, options) => {
|
||||
const config = {
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
||||
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
||||
commands: "./src/commands/commands.js",
|
||||
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
|
||||
success: ["./src/success/success.js", "./src/success/success.html"],
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".html", ".js"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "html-loader",
|
||||
options: {
|
||||
sources: {
|
||||
urlFilter: (attribute, value) => {
|
||||
// Don't try to resolve the runtime-injected config
|
||||
if (value.includes("config.js")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif|ico)$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name][ext][query]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "taskpane.html",
|
||||
template: "./src/taskpane/taskpane.html",
|
||||
chunks: ["polyfill", "taskpane"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "assets/*",
|
||||
to: "assets/[name][ext][query]",
|
||||
}
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "commands.html",
|
||||
template: "./src/commands/commands.html",
|
||||
chunks: ["polyfill", "commands"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "transit.html",
|
||||
template: "./src/transit/transit.html",
|
||||
chunks: ["polyfill", "transit"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "success.html",
|
||||
template: "./src/success/success.html",
|
||||
chunks: ["polyfill", "success"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new htmlWebpackInjectAttributesPlugin(),
|
||||
],
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
server: {
|
||||
type: "https",
|
||||
options:
|
||||
env.WEBPACK_BUILD || options.https !== undefined
|
||||
? options.https
|
||||
: await getHttpsOptions(),
|
||||
},
|
||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "thunderbird",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "web-ext run",
|
||||
"lint": "web-ext lint --source-dir=./src"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"web-ext": "^10.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { buildMeetingMessage } from "./lib/meeting-message.js";
|
||||
|
||||
console.log("[meeting-link] background loaded at", new Date().toISOString());
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log("[meeting-link] browser.calendar exists?", !!browser.calendar);
|
||||
if (browser.calendar) {
|
||||
console.log("[meeting-link] calendar namespace keys:",
|
||||
Object.keys(browser.calendar));
|
||||
}
|
||||
|
||||
// Try the most basic read call — list configured calendars.
|
||||
// The exact method name varies between experiment versions; we'll
|
||||
// try the common one first.
|
||||
if (browser.calendar.calendars?.query) {
|
||||
const calendars = await browser.calendar.calendars.query({});
|
||||
console.log("[meeting-link] calendars found:", calendars.length, calendars);
|
||||
} else {
|
||||
console.warn("[meeting-link] calendars.query not present — namespace shape:",
|
||||
browser.calendar);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[meeting-link] calendar probe failed:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
// Hardcoded for Spike 1. Spike 2 replaces this with a fetch() to your API.
|
||||
const STUB_MEETING_DATA = {
|
||||
url: "https://meet.example.com/m/abc-123-xyz",
|
||||
telephony: {
|
||||
phone_number: "+33123456789",
|
||||
pin_code: "1234567890",
|
||||
},
|
||||
};
|
||||
|
||||
browser.runtime.onMessage.addListener(async (msg) => {
|
||||
if (msg?.type === "GET_MEETING_MESSAGE") {
|
||||
try {
|
||||
const built = buildMeetingMessage(STUB_MEETING_DATA);
|
||||
return { ok: true, ...built };
|
||||
} catch (err) {
|
||||
console.error("[meeting-link] build failed", err);
|
||||
return { ok: false, error: String(err.message || err) };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Meeting Link (dev)",
|
||||
"version": "0.1.0",
|
||||
"description": "Spike 0 — verifying the dev loop",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "meeting-link-dev@yourcompany.example",
|
||||
"strict_min_version": "128.0"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"type": "module"
|
||||
},
|
||||
"compose_action": {
|
||||
"default_title": "Insert meeting link",
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_icon": "icons/icon-32.png"
|
||||
},
|
||||
"permissions": ["compose"],
|
||||
"experiment_apis": {
|
||||
"calendar_provider": {
|
||||
"schema": "experiments/calendar/schema/calendar-provider.json",
|
||||
"parent": {
|
||||
"scopes": ["addon_parent"],
|
||||
"paths": [["calendar", "provider"]],
|
||||
"script": "experiments/calendar/parent/ext-calendar-provider.js",
|
||||
"events": ["startup"]
|
||||
}
|
||||
},
|
||||
"calendar_calendars": {
|
||||
"schema": "experiments/calendar/schema/calendar-calendars.json",
|
||||
"parent": {
|
||||
"scopes": ["addon_parent"],
|
||||
"paths": [["calendar", "calendars"]],
|
||||
"script": "experiments/calendar/parent/ext-calendar-calendars.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Meeting link</title>
|
||||
<style>
|
||||
body { font: 13px system-ui; padding: 12px; min-width: 240px; margin: 0; }
|
||||
button { font: inherit; padding: 6px 12px; cursor: pointer; }
|
||||
#status { margin-top: 8px; color: #555; min-height: 1.2em; white-space: pre-wrap; }
|
||||
#status.error { color: #b00020; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="insert">Insert meeting link</button>
|
||||
<div id="status"></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
const insertBtn = document.getElementById("insert");
|
||||
const statusEl = document.getElementById("status");
|
||||
|
||||
function setStatus(text, isError = false) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
insertBtn.addEventListener("click", async () => {
|
||||
insertBtn.disabled = true;
|
||||
setStatus("Generating link…");
|
||||
|
||||
try {
|
||||
// 1. Find the compose tab this popup belongs to.
|
||||
// A compose_action popup is anchored to a compose window, so the
|
||||
// "active tab in the current window" is the compose tab itself.
|
||||
const [composeTab] = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
if (!composeTab) throw new Error("No compose tab found");
|
||||
|
||||
// 2. Read the current compose state — we need to know if we're
|
||||
// in HTML mode or plain-text mode, and we need the existing body
|
||||
// so we can append rather than overwrite.
|
||||
const details = await browser.compose.getComposeDetails(composeTab.id);
|
||||
|
||||
// 3. Ask the background to produce the meeting message.
|
||||
const reply = await browser.runtime.sendMessage({
|
||||
type: "GET_MEETING_MESSAGE",
|
||||
});
|
||||
if (!reply?.ok) throw new Error(reply?.error || "Background error");
|
||||
|
||||
// 4. Append to the existing body in the right format.
|
||||
if (details.isPlainText) {
|
||||
const newBody = (details.plainTextBody || "") + "\n\n" + reply.text;
|
||||
await browser.compose.setComposeDetails(composeTab.id, {
|
||||
plainTextBody: newBody,
|
||||
});
|
||||
} else {
|
||||
const newBody = appendHtmlBeforeBodyEnd(details.body || "", reply.html);
|
||||
await browser.compose.setComposeDetails(composeTab.id, {
|
||||
body: newBody,
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("Inserted ✓");
|
||||
setTimeout(() => window.close(), 600);
|
||||
} catch (err) {
|
||||
console.error("[meeting-link] popup insert failed", err);
|
||||
setStatus("Failed: " + (err.message || err), true);
|
||||
insertBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Append HTML right before </body>, or fall back to concatenation if no
|
||||
* </body> tag is present (Thunderbird's compose body is usually a full
|
||||
* HTML document, but be defensive).
|
||||
*/
|
||||
function appendHtmlBeforeBodyEnd(currentHtml, fragment) {
|
||||
const idx = currentHtml.toLowerCase().lastIndexOf("</body>");
|
||||
if (idx === -1) return currentHtml + fragment;
|
||||
return currentHtml.slice(0, idx) + fragment + currentHtml.slice(idx);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// web-ext-config.cjs
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
function thunderbirdBinary() {
|
||||
if (process.env.WEB_EXT_FIREFOX) return process.env.WEB_EXT_FIREFOX;
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Applications/Thunderbird Beta.app/Contents/MacOS/thunderbird";
|
||||
case "linux":
|
||||
return "/usr/bin/thunderbird"; // adjust to your install
|
||||
case "win32":
|
||||
return "C:\\Program Files\\Mozilla Thunderbird\\thunderbird.exe";
|
||||
default:
|
||||
throw new Error("Unsupported platform: " + process.platform);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sourceDir: "./src",
|
||||
artifactsDir: "./web-ext-artifacts",
|
||||
run: {
|
||||
firefox: thunderbirdBinary(),
|
||||
firefoxProfile: path.join(os.homedir(), ".thunderbird-dev-profile"),
|
||||
profileCreateIfMissing: true,
|
||||
keepProfileChanges: true,
|
||||
browserConsole: true,
|
||||
},
|
||||
ignoreFiles: ["package-lock.json", "web-ext-config.cjs", "*.md"],
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim AS base
|
||||
FROM python:3.13.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -15,16 +15,30 @@ COPY pyproject.toml .
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
FROM base AS development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir ".[dev]"
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "metadata_collector.py", "dev"]
|
||||
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Un-privileged user running the application
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY ./*.py /app/
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Storage parsers specific exceptions."""
|
||||
|
||||
|
||||
class MissingConfigError(Exception):
|
||||
"""Raised when a variable is not set in configuration."""
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Metadata agent that extracts metadata from active room."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
AgentServer,
|
||||
AgentSession,
|
||||
AutoSubscribe,
|
||||
JobContext,
|
||||
JobProcess,
|
||||
JobRequest,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("metadata-collector")
|
||||
|
||||
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
||||
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
server = AgentServer(
|
||||
permissions=WorkerPermissions(
|
||||
can_publish=False,
|
||||
can_publish_data=False,
|
||||
can_subscribe=True,
|
||||
hidden=True,
|
||||
),
|
||||
)
|
||||
server.setup_fnc = prewarm
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataEvent:
|
||||
"""A single timestamped event recorded during a meeting."""
|
||||
|
||||
participant_id: str
|
||||
type: str
|
||||
timestamp: datetime
|
||||
data: Optional[str] = None
|
||||
|
||||
def serialize(self) -> dict:
|
||||
"""Return a JSON-serializable dictionary representation of the event."""
|
||||
data = asdict(self)
|
||||
data["timestamp"] = self.timestamp.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class VADAgent(Agent):
|
||||
"""Agent that monitors voice activity for a specific participant."""
|
||||
|
||||
def __init__(self, participant_identity: str, events: List):
|
||||
"""Initialize with a participant identity and shared events list."""
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
self.events = events
|
||||
|
||||
async def on_enter(self) -> None:
|
||||
"""Initialize VAD monitoring for this participant."""
|
||||
|
||||
@self.session.on("user_state_changed")
|
||||
def on_user_state(event):
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
if event.new_state == "speaking":
|
||||
event = MetadataEvent(
|
||||
participant_id=self.participant_identity,
|
||||
type="speech_start",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self.events.append(event)
|
||||
|
||||
elif event.old_state == "speaking":
|
||||
event = MetadataEvent(
|
||||
participant_id=self.participant_identity,
|
||||
type="speech_end",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
class MetadataCollector:
|
||||
"""Collect meeting events across all participants in a room.
|
||||
|
||||
Creates one AgentSession per participant to capture VAD events
|
||||
(speech start/end), and listens for connection, disconnection,
|
||||
and chat events. Persists all collected events as JSON to S3
|
||||
on shutdown.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: JobContext, recording_id: str):
|
||||
"""Initialize metadata agent."""
|
||||
self.minio_client = Minio(
|
||||
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
|
||||
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
|
||||
)
|
||||
|
||||
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
|
||||
self.bucket_name = bucket_name
|
||||
else:
|
||||
raise MissingConfigError
|
||||
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
|
||||
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
|
||||
|
||||
# Storage for events
|
||||
self.events = []
|
||||
self.participants = {}
|
||||
|
||||
logger.info("MetadataCollector initialized")
|
||||
|
||||
def start(self):
|
||||
"""Start listening for room-level events."""
|
||||
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
|
||||
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
|
||||
|
||||
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
|
||||
|
||||
logger.info("Started listening for participant events")
|
||||
|
||||
async def on_chat_message_received(
|
||||
self, reader: rtc.TextStreamReader, participant_identity: str
|
||||
):
|
||||
"""Read a complete chat message and record it as an event."""
|
||||
full_text = await reader.read_all()
|
||||
logger.info("Received chat message from %s", participant_identity)
|
||||
|
||||
self.events.append(
|
||||
MetadataEvent(
|
||||
participant_id=participant_identity,
|
||||
type="chat_received",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
data=full_text,
|
||||
)
|
||||
)
|
||||
|
||||
def handle_chat_stream(self, reader, participant_identity):
|
||||
"""Schedule async processing of an incoming chat stream."""
|
||||
task = asyncio.create_task(
|
||||
self.on_chat_message_received(reader, participant_identity)
|
||||
)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
logger.info("Persisting metadata…")
|
||||
|
||||
participants = []
|
||||
for k, v in self.participants.items():
|
||||
participants.append({"participantId": k, "name": v})
|
||||
|
||||
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
|
||||
|
||||
payload = {
|
||||
"events": [event.serialize() for event in sorted_events],
|
||||
"participants": participants,
|
||||
}
|
||||
|
||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||
stream = BytesIO(data)
|
||||
|
||||
try:
|
||||
self.minio_client.put_object(
|
||||
self.bucket_name,
|
||||
self.output_filename,
|
||||
stream,
|
||||
length=len(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
logger.info(
|
||||
"Uploaded speaker meeting metadata",
|
||||
)
|
||||
except S3Error:
|
||||
logger.exception(
|
||||
"Failed to upload meeting metadata",
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
"""Close all sessions and cleanup resources."""
|
||||
logger.info("Closing all VAD monitoring sessions…")
|
||||
|
||||
await utils.aio.cancel_and_wait(*self._tasks)
|
||||
|
||||
await asyncio.gather(
|
||||
*[self._close_session(session) for session in self._sessions.values()],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
|
||||
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
|
||||
|
||||
logger.info("All VAD sessions closed")
|
||||
self.save()
|
||||
|
||||
async def on_participant_entrypoint(
|
||||
self, ctx: JobContext, participant: rtc.RemoteParticipant
|
||||
):
|
||||
"""Handle new participant by starting a VAD monitoring session."""
|
||||
if participant.identity in self._sessions:
|
||||
logger.debug("Session already exists for %s", participant.identity)
|
||||
return
|
||||
|
||||
self.events.append(
|
||||
MetadataEvent(
|
||||
participant_id=participant.identity,
|
||||
type="participant_connected",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
self.participants[participant.identity] = participant.name
|
||||
|
||||
logger.info("New participant connected: %s", participant.identity)
|
||||
try:
|
||||
session = await self._start_session(participant)
|
||||
self._sessions[participant.identity] = session
|
||||
except Exception:
|
||||
logger.exception("Failed to start session for %s", participant.identity)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle participant disconnection by closing VAD monitoring."""
|
||||
self.events.append(
|
||||
MetadataEvent(
|
||||
participant_id=participant.identity,
|
||||
type="participant_disconnected",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
session = self._sessions.pop(participant.identity, None)
|
||||
if session is None:
|
||||
logger.debug("No session found for %s", participant.identity)
|
||||
return
|
||||
|
||||
logger.info("Participant disconnected: %s", participant.identity)
|
||||
task = asyncio.create_task(self._close_session(session))
|
||||
self._tasks.add(task)
|
||||
|
||||
def on_close_done(_):
|
||||
self._tasks.discard(task)
|
||||
logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
)
|
||||
|
||||
task.add_done_callback(on_close_done)
|
||||
|
||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||
"""Update stored participant name when it changes."""
|
||||
logger.info("Participant's name changed: %s", participant.identity)
|
||||
self.participants[participant.identity] = participant.name
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start VAD monitoring session for participant."""
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
# Create session with VAD only - no STT, LLM, or TTS
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
turn_detection="vad",
|
||||
user_away_timeout=30.0,
|
||||
)
|
||||
|
||||
# Set up room IO to receive audio from this specific participant
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
audio_enabled=True,
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
audio_enabled=False,
|
||||
transcription_enabled=False,
|
||||
),
|
||||
)
|
||||
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=VADAgent(
|
||||
participant_identity=participant.identity, events=self.events
|
||||
)
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
async def _close_session(self, session: AgentSession) -> None:
|
||||
"""Close and cleanup VAD monitoring session."""
|
||||
try:
|
||||
await session.drain()
|
||||
await session.aclose()
|
||||
except Exception:
|
||||
logger.exception("Error closing session")
|
||||
|
||||
|
||||
async def handle_job_request(job_req: JobRequest) -> None:
|
||||
"""Accept or reject the job request based on agent presence in the room."""
|
||||
room_name = job_req.room.name
|
||||
recording_id = job_req.job.metadata
|
||||
agent_identity = f"{AGENT_NAME}-{room_name}"
|
||||
|
||||
async with api.LiveKitAPI() as lk:
|
||||
try:
|
||||
resp = await lk.room.list_participants(
|
||||
list=api.ListParticipantsRequest(room=room_name)
|
||||
)
|
||||
already_present = any(
|
||||
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
|
||||
and p.identity == agent_identity
|
||||
for p in resp.participants
|
||||
)
|
||||
if already_present:
|
||||
logger.info("Agent already in the room '%s' — reject", room_name)
|
||||
await job_req.reject()
|
||||
else:
|
||||
logger.info(
|
||||
"Accept job for '%s' — identity=%s", room_name, agent_identity
|
||||
)
|
||||
await job_req.accept(identity=agent_identity, metadata=recording_id)
|
||||
except Exception:
|
||||
logger.exception("Error treating the job for '%s'", room_name)
|
||||
await job_req.reject()
|
||||
|
||||
|
||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the metadata collector."""
|
||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||
recording_id = ctx.job.metadata
|
||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||
metadata_collector.start()
|
||||
|
||||
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
|
||||
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
async def cleanup():
|
||||
logger.info("Shutting down metadata collector…")
|
||||
await metadata_collector.aclose()
|
||||
|
||||
ctx.add_shutdown_callback(cleanup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli.run_app(server)
|
||||
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
|
||||
def create_stt_provider():
|
||||
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
)
|
||||
vad = self.ctx.proc.userdata.get("vad", None)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
if ENABLE_SILERO_VAD:
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -1,21 +1,26 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.2.0"
|
||||
version = "1.15.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
"livekit-plugins-deepgram==1.3.10",
|
||||
"livekit-plugins-silero==1.3.10",
|
||||
"livekit-agents==1.4.5",
|
||||
"livekit-plugins-deepgram==1.4.5",
|
||||
"livekit-plugins-silero==1.4.5",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.1"
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.5",
|
||||
"minio==7.2.15"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.14.4",
|
||||
"ruff==0.15.6",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core add-ons module."""
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from core.models import User
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
_PUBLIC_SESSION_FIELDS = frozenset(
|
||||
{"state", "access_token", "token_type", "expires_in", "scope"}
|
||||
)
|
||||
|
||||
|
||||
class SessionDataError(Exception):
|
||||
"""Raised when session data is invalid or malformed."""
|
||||
|
||||
|
||||
class CSRFTokenError(Exception):
|
||||
"""Raised when CSRF token verification fails."""
|
||||
|
||||
|
||||
class TransitTokenError(Exception):
|
||||
"""Raised when a transit token is invalid or expired."""
|
||||
|
||||
|
||||
class SessionExpiredError(Exception):
|
||||
"""Raised when a session has expired."""
|
||||
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
"""Raised when a session is not found."""
|
||||
|
||||
|
||||
class SuspiciousSessionError(Exception):
|
||||
"""Raised when session state indicates a possible attack or bug."""
|
||||
|
||||
|
||||
class SessionState(str, Enum):
|
||||
"""Add-on authentication session lifecycle states."""
|
||||
|
||||
PENDING = "pending"
|
||||
AUTHENTICATED = "authenticated"
|
||||
|
||||
|
||||
class TransitTokenState(str, Enum):
|
||||
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
|
||||
|
||||
PENDING = "pending"
|
||||
CONSUMED = "consumed"
|
||||
|
||||
|
||||
class TokenExchangeService:
|
||||
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
||||
|
||||
def __init__(self):
|
||||
"""Build the underlying JWT service and validate required settings."""
|
||||
|
||||
if not settings.ADDONS_CSRF_SECRET:
|
||||
raise ImproperlyConfigured("CSRF Secret is required.")
|
||||
|
||||
if not settings.ADDONS_TOKEN_SCOPE:
|
||||
raise ImproperlyConfigured("Token scope must be defined.")
|
||||
|
||||
self._token_service = JwtTokenService(
|
||||
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
issuer=settings.ADDONS_TOKEN_ISSUER,
|
||||
audience=settings.ADDONS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.ADDONS_TOKEN_TTL,
|
||||
token_type=settings.ADDONS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(prefix: str, token: str) -> str:
|
||||
"""Build a namespaced cache key: ``addons_{prefix}_{token}``."""
|
||||
return f"addons_{prefix}_{token}"
|
||||
|
||||
@staticmethod
|
||||
def _derive_csrf_token(session_id: str) -> str:
|
||||
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET."""
|
||||
return hmac.new(
|
||||
settings.ADDONS_CSRF_SECRET.encode("utf-8"),
|
||||
session_id.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_not_expired(session_data: dict) -> int:
|
||||
"""Return remaining seconds until expiry, or raise if missing/malformed/expired."""
|
||||
expires_at_str = session_data.get("expires_at")
|
||||
if expires_at_str is None:
|
||||
raise SessionDataError("Invalid session data: missing expiration.")
|
||||
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
except ValueError as e:
|
||||
raise SessionDataError("Invalid session data: malformed expiration.") from e
|
||||
|
||||
remaining_seconds = int(
|
||||
(expires_at - datetime.now(timezone.utc)).total_seconds()
|
||||
)
|
||||
|
||||
if remaining_seconds <= 0:
|
||||
raise SessionExpiredError("Session expired.")
|
||||
|
||||
return remaining_seconds
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate a high-entropy URL-safe session_id."""
|
||||
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
|
||||
|
||||
def _generate_transit_token(self) -> str:
|
||||
"""Generate a high-entropy URL-safe transit token."""
|
||||
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
|
||||
|
||||
def init_session(self) -> tuple[str, str, str]:
|
||||
"""Create a new pending session and its transit binding.
|
||||
|
||||
Returns:
|
||||
(transit_token, session_id, csrf_token)
|
||||
"""
|
||||
session_id = self._generate_session_id()
|
||||
transit_token = self._generate_transit_token()
|
||||
csrf_token = self._derive_csrf_token(session_id)
|
||||
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL)
|
||||
).isoformat()
|
||||
|
||||
session_data = {
|
||||
"state": SessionState.PENDING,
|
||||
"expires_at": expires_at,
|
||||
"transit_token": transit_token,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
||||
session_data,
|
||||
settings.ADDONS_SESSION_TTL,
|
||||
)
|
||||
|
||||
transit_token_data = {
|
||||
"session_id": session_id,
|
||||
"state": TransitTokenState.PENDING,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token),
|
||||
transit_token_data,
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL,
|
||||
)
|
||||
|
||||
return transit_token, session_id, csrf_token
|
||||
|
||||
def verify_csrf(self, session_id: str, submitted_csrf: str) -> None:
|
||||
"""Constant-time verify submitted_csrf against HMAC(session_id). Raise on mismatch."""
|
||||
expected_csrf = self._derive_csrf_token(session_id)
|
||||
if not hmac.compare_digest(expected_csrf, submitted_csrf):
|
||||
raise CSRFTokenError("Invalid CSRF token.")
|
||||
|
||||
def consume_transit_token(self, transit_token: str) -> str:
|
||||
"""Mark transit token consumed and return its session_id.
|
||||
|
||||
A replay (second consume) evicts the session as a security cleanup and raises.
|
||||
|
||||
Raises:
|
||||
TransitTokenError: If token is unknown, expired, or already consumed.
|
||||
"""
|
||||
cache_key = self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
||||
|
||||
transit_token_data = cache.get(cache_key)
|
||||
|
||||
if transit_token_data is None:
|
||||
# Indistinguishable from here: either the token was never issued (attacker
|
||||
# probing or client bug) or it was issued but expired before consumption.
|
||||
logger.warning(
|
||||
"Transit token not found in cache (unknown or expired).",
|
||||
)
|
||||
raise TransitTokenError("Invalid or expired transit token.")
|
||||
|
||||
state = transit_token_data.get("state", None)
|
||||
session_id = transit_token_data.get("session_id", None)
|
||||
|
||||
if not session_id:
|
||||
logger.warning("Transit token data missing session_id.")
|
||||
raise TransitTokenError("Invalid transit token.")
|
||||
|
||||
if state == TransitTokenState.CONSUMED:
|
||||
logger.warning(
|
||||
"Replay on session %s",
|
||||
session_id,
|
||||
)
|
||||
|
||||
# Security cleanup: a replay attempt means the transit token leaked
|
||||
# (or an attacker is probing). Evict the session so the authenticated
|
||||
# tokens — if they exist — can no longer be polled.
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
|
||||
raise TransitTokenError("Transit token already consumed.")
|
||||
|
||||
new_transit_token_data = {
|
||||
"state": TransitTokenState.CONSUMED,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
cache_key,
|
||||
new_transit_token_data,
|
||||
settings.ADDONS_SESSION_TTL,
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
@staticmethod
|
||||
def is_session_pending(session_data: dict) -> bool:
|
||||
"""Return True if the public session dict is still in the pending state."""
|
||||
return session_data.get("state") == SessionState.PENDING
|
||||
|
||||
def _get_session_data(self, session_id: str) -> dict:
|
||||
"""Fetch raw session data from cache, or raise SessionNotFoundError."""
|
||||
|
||||
if not session_id:
|
||||
raise SessionNotFoundError("Session not found.")
|
||||
|
||||
data = cache.get(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
if data is None:
|
||||
raise SessionNotFoundError("Session not found.")
|
||||
return data
|
||||
|
||||
def get_session(self, session_id: str) -> dict:
|
||||
"""Return the public session view; evict the session on authenticated read.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If session is not found.
|
||||
SessionDataError: If session data is missing the state field.
|
||||
"""
|
||||
|
||||
# raises if session is not found
|
||||
session_data = self._get_session_data(session_id)
|
||||
|
||||
if "state" not in session_data:
|
||||
raise SessionDataError("Invalid session data: missing state field.")
|
||||
|
||||
# One-time read: clear both bindings for authenticated sessions
|
||||
if session_data["state"] == SessionState.AUTHENTICATED:
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
|
||||
# Return public fields only
|
||||
return {k: v for k, v in session_data.items() if k in _PUBLIC_SESSION_FIELDS}
|
||||
|
||||
def _validate_transit_token_state(self, session_data: dict) -> None:
|
||||
"""Assert the session's transit token exists in cache and is in CONSUMED state.
|
||||
|
||||
Raises:
|
||||
SessionDataError: session_data is missing the transit_token field.
|
||||
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
|
||||
"""
|
||||
|
||||
transit_token = session_data.get("transit_token", None)
|
||||
if transit_token is None:
|
||||
raise SessionDataError("Invalid session data: missing transit_token field.")
|
||||
|
||||
transit_token_data = cache.get(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
||||
)
|
||||
|
||||
if transit_token_data is None:
|
||||
logger.warning("Transit token missing when setting access token.")
|
||||
raise SuspiciousSessionError("Transit token not found.")
|
||||
|
||||
if transit_token_data.get("state") != TransitTokenState.CONSUMED:
|
||||
logger.warning("Access token requested without completing transit flow.")
|
||||
raise SuspiciousSessionError("Transit token not consumed.")
|
||||
|
||||
def set_access_token(self, user: User, session_id: str) -> None:
|
||||
"""Authenticate a pending session by minting a JWT and storing it on the session.
|
||||
|
||||
Non-pending sessions are evicted as a security cleanup before raising.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If session doesn't exist.
|
||||
SessionDataError: If session data is malformed.
|
||||
SessionExpiredError: If session has expired.
|
||||
SuspiciousSessionError: If session is not pending or transit wasn't consumed.
|
||||
"""
|
||||
|
||||
# raises if session is not found
|
||||
session_data = self._get_session_data(session_id)
|
||||
|
||||
if session_data.get("state") != SessionState.PENDING:
|
||||
logger.warning(
|
||||
"Session's state is not pending. Suspicious.",
|
||||
)
|
||||
# Security cleanup: evict the session so any cached tokens cannot be polled.
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
raise SuspiciousSessionError("Session is not in pending state.")
|
||||
|
||||
# raises if transit_token is invalid
|
||||
try:
|
||||
self._validate_transit_token_state(session_data)
|
||||
except SuspiciousSessionError:
|
||||
# Security cleanup: evict the session.
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
raise
|
||||
|
||||
# raises if session is expired
|
||||
remaining_seconds = self._validate_session_not_expired(session_data)
|
||||
|
||||
response = self._token_service.generate_jwt(user, settings.ADDONS_TOKEN_SCOPE)
|
||||
|
||||
new_data = {
|
||||
"access_token": response["access_token"],
|
||||
"token_type": response["token_type"],
|
||||
"expires_in": response["expires_in"],
|
||||
"scope": response["scope"],
|
||||
"expires_at": session_data["expires_at"],
|
||||
"state": SessionState.AUTHENTICATED,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
||||
new_data,
|
||||
remaining_seconds,
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Add-ons API endpoints"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from rest_framework import decorators, viewsets
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import status as drf_status
|
||||
|
||||
from core.addons.service import (
|
||||
CSRFTokenError,
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
TransitTokenError,
|
||||
)
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.api.permissions import IsAuthenticated
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SessionViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for managing add-on authentication sessions via token exchange.
|
||||
|
||||
Implements a three-step flow that lets a third-party add-on (running in an
|
||||
embedded iframe) obtain an access token without exposing it to client-side
|
||||
JavaScript:
|
||||
|
||||
1. /init: the add-on opens a session and receives a short-lived transit
|
||||
token (used to bootstrap the OAuth-style exchange in a dialog) and a
|
||||
CSRF token. The opaque session id is stored in an HttpOnly, Secure,
|
||||
SameSite=None cookie so it can accompany cross-origin polls.
|
||||
2. /poll: the add-on polls until the session transitions from pending to
|
||||
authenticated. On the terminal read, the session payload (access
|
||||
token, token type, expiry, etc.) is returned, the session is evicted
|
||||
server-side, and the session cookie is cleared so the tokens can be
|
||||
retrieved exactly once.
|
||||
3. /exchange: called from the post-login callback page on our own domain,
|
||||
after the user has authenticated in a dialog opened by the addon. The
|
||||
transit token (carried client-side via postMessage + sessionStorage)
|
||||
is redeemed here for the authenticated user's access token, which is
|
||||
stored server-side against the session. Requires an authenticated
|
||||
user — that user is whose access token gets bound to the session.
|
||||
|
||||
/init and /poll authenticate the caller through the session cookie +
|
||||
CSRF token pair alone — no user login is required, since the whole point
|
||||
of the flow is to bootstrap one. /exchange, by contrast, requires an
|
||||
authenticated user and does not use the addonsSid cookie.
|
||||
"""
|
||||
|
||||
throttle_classes = []
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="init",
|
||||
authentication_classes=[],
|
||||
permission_classes=[],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def init(self, request):
|
||||
"""Open a new add-on authentication session.
|
||||
|
||||
Creates a fresh session server-side and returns the credentials the
|
||||
add-on needs to drive the rest of the flow.
|
||||
"""
|
||||
|
||||
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
|
||||
|
||||
response = drf_response.Response(
|
||||
{"transit_token": transit_token, "csrf_token": csrf_token},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# SameSite=None allows the cookie to be sent on cross-origin requests,
|
||||
# which is required because the /poll endpoint is called from an iframe
|
||||
# embedded in a third-party site. Secure=True is mandatory when SameSite=None.
|
||||
# HttpOnly prevents JS access, so the cookie can only be read by the server.
|
||||
response.set_cookie(
|
||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
||||
value=session_id,
|
||||
max_age=settings.ADDONS_SESSION_TTL,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="None",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="poll",
|
||||
authentication_classes=[],
|
||||
permission_classes=[],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def poll(self, request):
|
||||
"""Poll a session for its current state and, if terminal, consume it.
|
||||
|
||||
Authenticates the caller using the addonsSid cookie (set by
|
||||
/init) together with the X-CSRFToken header, which must match
|
||||
the CSRF token issued for that session. The session id alone is not
|
||||
sufficient — both must be presented and must correspond.
|
||||
|
||||
Behavior depends on the session's current state:
|
||||
|
||||
- **Pending**: the token exchange has not yet completed. Returns
|
||||
202 Accepted with `{"state": "pending"}`. The cookie is preserved
|
||||
so the add-on can keep polling.
|
||||
- **Authenticated** (or any other terminal state): returns 200 OK
|
||||
with the session payload (access token, token type, expiry, etc.)
|
||||
and clears the `addonsSid` cookie. The session is also evicted
|
||||
server-side on this terminal read, so the tokens can be retrieved
|
||||
exactly once.
|
||||
|
||||
A CSRF mismatch is treated as a `SuspiciousOperation` rather than a
|
||||
normal 4xx, so it is logged by Django's security middleware and
|
||||
surfaced as a 400 without leaking which check failed.
|
||||
"""
|
||||
|
||||
session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE)
|
||||
submitted_csrf = request.headers.get("X-CSRFToken")
|
||||
|
||||
if not session_id:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing credentials."},
|
||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not submitted_csrf:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing CSRF token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = TokenExchangeService()
|
||||
|
||||
try:
|
||||
service.verify_csrf(session_id, submitted_csrf)
|
||||
except CSRFTokenError as e:
|
||||
raise SuspiciousOperation(str(e)) from e
|
||||
|
||||
try:
|
||||
session = service.get_session(session_id)
|
||||
except SessionNotFoundError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Session not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except SessionDataError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired session."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if service.is_session_pending(session):
|
||||
return drf_response.Response(
|
||||
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
response = drf_response.Response(session, status=drf_status.HTTP_200_OK)
|
||||
response.delete_cookie(
|
||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
||||
samesite="None",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="exchange",
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def exchange(self, request):
|
||||
"""Redeem a transit token for an access token bound to the current user.
|
||||
|
||||
Called from the post-OIDC callback page on our own domain. The transit
|
||||
token was issued by /init, passed to the authentication dialog via
|
||||
postMessage, stashed in sessionStorage, and read back by this page
|
||||
after login completes.
|
||||
|
||||
The authenticated user (request.user) is whose access token gets stored
|
||||
against the session. On success, the addon's next /poll will transition
|
||||
from pending to authenticated and receive the token payload.
|
||||
|
||||
Transit tokens are single-use: a replayed token is rejected with 400.
|
||||
"""
|
||||
|
||||
transit_token = request.data.get("transit_token")
|
||||
if not transit_token:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing transit_token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = TokenExchangeService()
|
||||
|
||||
try:
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
except TransitTokenError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired transit token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
service.set_access_token(request.user, session_id)
|
||||
except SessionNotFoundError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Session not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except (SessionDataError, SessionExpiredError, SuspiciousSessionError):
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired session."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)
|
||||
@@ -115,6 +115,10 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
return super().get_queryset(request).prefetch_related("accesses__user")
|
||||
|
||||
def get_owner(self, obj):
|
||||
"""Return the owner of the room for display in the admin list."""
|
||||
|
||||
@@ -138,6 +142,7 @@ class RecordingAccessInline(admin.TabularInline):
|
||||
|
||||
model = models.RecordingAccess
|
||||
extra = 0
|
||||
autocomplete_fields = ["user"]
|
||||
|
||||
|
||||
@admin.action(description=_("Resend notification to external service"))
|
||||
@@ -192,6 +197,38 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
|
||||
)
|
||||
|
||||
|
||||
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
|
||||
def mark_as_failed_to_stop(modeladmin, request, queryset):
|
||||
"""Force selected recordings status to failed_to_stop."""
|
||||
|
||||
eligible_statuses = [
|
||||
models.RecordingStatusChoices.ACTIVE,
|
||||
models.RecordingStatusChoices.INITIATED,
|
||||
models.RecordingStatusChoices.STOPPED,
|
||||
]
|
||||
|
||||
eligible = queryset.filter(status__in=eligible_statuses)
|
||||
skipped = queryset.exclude(status__in=eligible_statuses).count()
|
||||
|
||||
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
|
||||
|
||||
if updated > 0:
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
|
||||
% {"count": updated},
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
if skipped > 0:
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Skipped %(count)s recording(s) with an ineligible status.")
|
||||
% {"count": skipped},
|
||||
level=messages.WARNING,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Recording)
|
||||
class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
@@ -207,9 +244,19 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
"worker_id",
|
||||
)
|
||||
list_filter = ["status", "room", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
actions = [resend_notification]
|
||||
list_filter = ["created_at"]
|
||||
list_select_related = ("room",)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"created_at",
|
||||
"options",
|
||||
"mode",
|
||||
"room",
|
||||
"status",
|
||||
"updated_at",
|
||||
"worker_id",
|
||||
)
|
||||
actions = [resend_notification, mark_as_failed_to_stop]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
@@ -261,7 +308,7 @@ class ApplicationAdmin(admin.ModelAdmin):
|
||||
|
||||
form = ApplicationAdminForm
|
||||
|
||||
list_display = ("id", "name", "client_id", "get_scopes_display")
|
||||
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
|
||||
fields = [
|
||||
"name",
|
||||
"id",
|
||||
@@ -270,6 +317,7 @@ class ApplicationAdmin(admin.ModelAdmin):
|
||||
"scopes",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"is_active",
|
||||
]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
inlines = [ApplicationDomainInline]
|
||||
|
||||
@@ -43,6 +43,21 @@ def get_frontend_configuration(request):
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
},
|
||||
"background_image": {
|
||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||
"max_count_by_user"
|
||||
],
|
||||
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||
"max_size"
|
||||
],
|
||||
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||
"allowed_extensions"
|
||||
],
|
||||
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||
"allowed_mimetypes"
|
||||
],
|
||||
},
|
||||
"telephony": {
|
||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||
|
||||
@@ -13,6 +13,9 @@ class FeatureFlag:
|
||||
"recording": "RECORDING_ENABLE",
|
||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""API filters for meet' core application."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
from django_filters import BooleanFilter
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class FileFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for filtering files.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = ["type"]
|
||||
|
||||
|
||||
class ListFileFilter(FileFilter):
|
||||
"""Filter class dedicated to the file viewset list method."""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
|
||||
is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted")
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = ["is_creator_me", "type", "upload_state", "is_deleted"]
|
||||
|
||||
def filter_is_deleted(self, queryset, name, value):
|
||||
"""
|
||||
Filter files based on whether they are deleted or not.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/files/?is_deleted=false
|
||||
→ Filters files that were not deleted
|
||||
"""
|
||||
if value is None:
|
||||
return queryset
|
||||
|
||||
lookup = "__".join([name, "isnull"])
|
||||
return queryset.filter(**{lookup: not value})
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
"""
|
||||
Filter files based on the `creator` being the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/files/?is_creator_me=true
|
||||
→ Filters files created by the logged-in user
|
||||
- /api/v1.0/files/?is_creator_me=false
|
||||
→ Filters files created by other users
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(creator=user)
|
||||
|
||||
return queryset.exclude(creator=user)
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
@@ -106,3 +109,30 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
|
||||
if not request.auth or not hasattr(request.auth, "video"):
|
||||
return False
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
|
||||
class FilePermission(IsAuthenticated):
|
||||
"""
|
||||
Permissions applying to the file API endpoint.
|
||||
Handling soft deletions specificities
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Allow access only to authenticated users."""
|
||||
if not settings.FILE_UPLOAD_ENABLED:
|
||||
raise Http404
|
||||
|
||||
return super().has_permission(request, view)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""
|
||||
Return a 404 on deleted files or if the user is not the owner
|
||||
"""
|
||||
|
||||
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
|
||||
raise Http404
|
||||
|
||||
if obj.creator != request.user:
|
||||
raise Http404
|
||||
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
import logging
|
||||
from os.path import splitext
|
||||
from typing import Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from livekit.api import ParticipantPermission
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
@@ -23,6 +34,15 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
"""
|
||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||
@@ -201,6 +221,30 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||
|
||||
|
||||
class RecordingOptions(BaseModel):
|
||||
"""Configuration options for recording.
|
||||
|
||||
Attributes:
|
||||
language: ISO 639-1 language code compatible with whisperX.
|
||||
When `None`, the transcription engine will attempt to
|
||||
auto-detect the spoken language.
|
||||
transcribe: Whether to transcribe the recorded audio.
|
||||
When `None`, falls back to the application default.
|
||||
original_mode: The original recording mode before any override.
|
||||
Must be one of the valid RecordingModeChoices values when provided.
|
||||
collect_metadata: Whether to collect additional metadata during recording.
|
||||
When `None`, no metadata are collected.
|
||||
|
||||
"""
|
||||
|
||||
language: str | None = None
|
||||
transcribe: bool | None = None
|
||||
collect_metadata: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate start recording requests."""
|
||||
|
||||
@@ -213,10 +257,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
options = serializers.JSONField(
|
||||
options = SchemaField(
|
||||
schema=RecordingOptions | None,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=dict,
|
||||
help_text="Recording options",
|
||||
)
|
||||
|
||||
|
||||
@@ -261,6 +306,29 @@ 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):
|
||||
"""Validate participant update data."""
|
||||
|
||||
@@ -272,10 +340,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
allow_null=True,
|
||||
help_text="Participant attributes as JSON object",
|
||||
)
|
||||
permission = serializers.DictField(
|
||||
permission = SchemaField(
|
||||
schema=ParticipantPermission | None,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Participant permission as JSON object",
|
||||
help_text="Participant permissions",
|
||||
)
|
||||
name = serializers.CharField(
|
||||
max_length=255,
|
||||
@@ -285,6 +354,25 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
help_text="Display name for the participant",
|
||||
)
|
||||
|
||||
def validate_permission(self, permission):
|
||||
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
||||
|
||||
if permission is None:
|
||||
return None
|
||||
|
||||
suspicious_fields = [
|
||||
field
|
||||
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
||||
if getattr(permission, field) is not None
|
||||
]
|
||||
if suspicious_fields:
|
||||
raise SuspiciousOperation(
|
||||
f"Setting the following participant permissions is not allowed: "
|
||||
f"{', '.join(suspicious_fields)}."
|
||||
)
|
||||
|
||||
return permission
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure at least one update field is provided."""
|
||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||
@@ -300,12 +388,156 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
f"{', '.join(update_fields)}."
|
||||
)
|
||||
|
||||
if "permission" in attrs:
|
||||
try:
|
||||
ParticipantPermission(**attrs["permission"])
|
||||
except ValueError as e:
|
||||
return attrs
|
||||
|
||||
|
||||
class ListFileSerializer(serializers.ModelSerializer):
|
||||
"""Serialize File model for the API."""
|
||||
|
||||
url = serializers.SerializerMethodField(read_only=True)
|
||||
creator = UserLightSerializer(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"title",
|
||||
"type",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"filename",
|
||||
"upload_state",
|
||||
"mimetype",
|
||||
"size",
|
||||
"description",
|
||||
"url",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"filename",
|
||||
"upload_state",
|
||||
"mimetype",
|
||||
"size",
|
||||
"url",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
def get_url(self, obj):
|
||||
"""Return the URL of the file."""
|
||||
if obj.is_pending_upload:
|
||||
return None
|
||||
|
||||
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
|
||||
|
||||
def get_abilities(self, file) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if not request:
|
||||
return {}
|
||||
|
||||
return file.get_abilities(request.user)
|
||||
|
||||
|
||||
class FileSerializer(ListFileSerializer):
|
||||
"""Default serializer File model for the API."""
|
||||
|
||||
def create(self, validated_data):
|
||||
raise NotImplementedError("Create method can not be used.")
|
||||
|
||||
|
||||
class CreateFileSerializer(ListFileSerializer):
|
||||
"""Serializer used to create a new file"""
|
||||
|
||||
title = serializers.CharField(max_length=255, required=False)
|
||||
policy = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = [*ListFileSerializer.Meta.fields, "policy"]
|
||||
read_only_fields = [
|
||||
*(
|
||||
field
|
||||
for field in ListFileSerializer.Meta.read_only_fields
|
||||
if field != "filename"
|
||||
),
|
||||
"policy",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
"""Force the id field to be writable."""
|
||||
fields = super().get_fields()
|
||||
fields["id"].read_only = False
|
||||
|
||||
return fields
|
||||
|
||||
def validate_id(self, value):
|
||||
"""Ensure the provided ID does not already exist when creating a new file."""
|
||||
request = self.context.get("request")
|
||||
|
||||
# Only check this on POST (creation)
|
||||
if request and models.File.objects.filter(id=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A file with this ID already exists. You cannot override it.",
|
||||
code="file_create_existing_id",
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate extension and fill title."""
|
||||
# we run the default validation first to make sure the base data in attrs is ok
|
||||
attrs = super().validate(attrs)
|
||||
|
||||
filename_root, ext = splitext(attrs["filename"])
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]]
|
||||
if ext.lower() not in config_for_file_type["allowed_extensions"]:
|
||||
logger.info(
|
||||
"create_item: file extension not allowed %s for filename %s",
|
||||
ext,
|
||||
attrs["filename"],
|
||||
)
|
||||
raise serializers.ValidationError(
|
||||
{"permission": f"Invalid permission: {str(e)}"}
|
||||
) from e
|
||||
{"filename": _("This file extension is not allowed.")},
|
||||
code="item_create_file_extension_not_allowed",
|
||||
)
|
||||
|
||||
# The title will be the filename if not provided
|
||||
if not attrs.get("title", None):
|
||||
attrs["title"] = filename_root
|
||||
|
||||
return attrs
|
||||
|
||||
def get_policy(self, file):
|
||||
"""Return the policy to use if the item is a file."""
|
||||
|
||||
if file.upload_state == models.FileUploadStateChoices.READY:
|
||||
return None
|
||||
|
||||
return utils.generate_upload_policy(file)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
raise NotImplementedError("Update method can not be used.")
|
||||
|
||||
|
||||
class RaiseHandSerializer(BaseValidationOnlySerializer):
|
||||
"""Serializer for raising or lowering a participant's hand in a room."""
|
||||
|
||||
raised = serializers.BooleanField()
|
||||
|
||||
|
||||
class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
||||
"""Serializer for renaming a participant in a room."""
|
||||
|
||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
capture_message(message, "warning")
|
||||
|
||||
|
||||
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the authenticated user ID as the throttle cache key."""
|
||||
|
||||
if request.user and not request.user.is_authenticated:
|
||||
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
|
||||
|
||||
return super().get_cache_key(request, view)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
|
||||
Keying on the cookie rather than the IP address prevents penalising
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
security measure against brute-force or DoS attacks. This throttle exists
|
||||
solely to guard against accidental hammering from buggy clients.
|
||||
"""
|
||||
|
||||
if request.user and request.user.is_authenticated:
|
||||
return None # Only throttle unauthenticated requests.
|
||||
|
||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||
|
||||
if participant_id is None:
|
||||
return None # No throttling for cookieless requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
"ident": participant_id,
|
||||
}
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||