Compare commits
491 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35951ba2a6 | |||
| 72184e1370 | |||
| 1b4a8fbac2 | |||
| 1e2fad5444 | |||
| 96f97ed2d0 | |||
| 02d16cb55c | |||
| 7268ff6777 | |||
| cca5bc2186 | |||
| ec67a12fe4 | |||
| 05f32d008a | |||
| 964b3cd452 | |||
| c7ca5a621f | |||
| 90ebe231ef | |||
| 04f2a9ebdc | |||
| 6a8eb79b41 | |||
| bc35046b3a | |||
| 1612d8b2d4 | |||
| f8937fc0a1 | |||
| 97b5e3e65c | |||
| b917d82f7e | |||
| 82d146cdf5 | |||
| cbfeea0a4e | |||
| a695758da4 | |||
| 4c5b6de8f3 | |||
| cf4e347589 | |||
| fc260b2686 | |||
| cd7799997e | |||
| a2bccf4f4f | |||
| 6830250f2c | |||
| 0c0ce87947 | |||
| 597eba6e8a | |||
| 47dbc271ba | |||
| c3adcc8ff3 | |||
| 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 | |||
| a71a1fd968 | |||
| 40af264562 | |||
| 8b2d06976e | |||
| 58313666ed | |||
| f3c8aec189 | |||
| 0a0c7ba618 | |||
| d7ad5aed05 | |||
| 4acc9cf40d | |||
| 13d0d3d801 | |||
| 47cd3eff74 | |||
| 5769203705 | |||
| cadc186c62 | |||
| 5be7595533 | |||
| 0fe8d9b681 | |||
| 83654cf7c0 | |||
| f6cdb1125b | |||
| 2863aa832d | |||
| 48af2e3a5f | |||
| 8a0dfd1478 | |||
| 37a2f3985a | |||
| 39271544d7 | |||
| f7b45622bc | |||
| f3e2bbf701 | |||
| 6e1ad7fca5 | |||
| d9dbededee | |||
| 70403ad0d8 | |||
| 9d69fe4f4f | |||
| 08f281e778 | |||
| da3dfedcbc | |||
| 16badde82d | |||
| 57a7523cc4 | |||
| 398ef1ae8a | |||
| f7d463f380 | |||
| 5e1705d259 | |||
| 236245740f | |||
| 9ebf2f277b | |||
| 049a9079c4 | |||
| 19f8c96e9d | |||
| 857b4bd1f1 | |||
| 309c532811 | |||
| 4e5032a7a4 | |||
| 4cb6320b83 | |||
| 587a5bc574 | |||
| 0d8c76cd03 | |||
| b19ac7f82b | |||
| d3e6af6f82 | |||
| 2fbb476b02 | |||
| 1b2139a9ff | |||
| 54e47e33a9 | |||
| 20b99cf2ad | |||
| db75b0eae9 | |||
| 5163f849e4 | |||
| 4345711771 | |||
| 7c690c369e | |||
| ef09629566 | |||
| cff1dbf39e | |||
| b466515306 | |||
| c678e9420e | |||
| 3af115dafb | |||
| 0daa6d0432 | |||
| 493d7b96f1 | |||
| c2c478c367 | |||
| b5895ccba0 | |||
| aff87d4953 | |||
| c81ef38005 | |||
| 4256eb403d | |||
| 43f3e4691b | |||
| 10aac93c36 | |||
| 4e6bc157b0 | |||
| fe83c5fa07 | |||
| 827014c952 | |||
| 9523f52546 | |||
| 8348a55f7e | |||
| a4b76433ab | |||
| ae863418cd | |||
| dcdae26610 | |||
| 90c0442d35 | |||
| 9093371d25 | |||
| 1d45d3aa7c | |||
| fcb89c520e | |||
| 309ce0989d | |||
| a6c154374f | |||
| b0e27b38e2 | |||
| 9bdc68f9c9 | |||
| 4545e9fa1e | |||
| 3f1edbf134 | |||
| 4f2764eef4 | |||
| b11cc6e9da | |||
| 0a7eb97c90 | |||
| db188075af |
@@ -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
|
||||
@@ -147,6 +185,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -154,6 +200,7 @@ jobs:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -161,10 +208,18 @@ jobs:
|
||||
|
||||
build-and-push-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -178,6 +233,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -185,12 +248,15 @@ jobs:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build-and-push-frontend-generic
|
||||
- build-and-push-frontend-dinum
|
||||
|
||||
@@ -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,63 +105,72 @@ 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"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
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 +184,8 @@ jobs:
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
@@ -204,8 +224,6 @@ jobs:
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
@@ -213,10 +231,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 +243,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 +277,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 +291,62 @@ jobs:
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
run: uv run pytest -n 2
|
||||
|
||||
test-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
|
||||
env:
|
||||
V1_TENANT_ID: 'test-tenant'
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL: "large-v2"
|
||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
||||
LLM_BASE_URL: "https://configure-your-url.com"
|
||||
LLM_API_KEY: "test-llm-secret"
|
||||
LLM_MODEL: "test-llm-model"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Run summary tests
|
||||
run: ~/.local/bin/pytest
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
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 +359,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 +379,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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
@@ -9,4 +8,343 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
-
|
||||
## [1.16.0] - 2026-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒️(backend) add validation of Room.configuration
|
||||
- ✨(helm) add support multiple transcribe worker / endpoint #1247
|
||||
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
|
||||
- ✨(summary) add speaker-to-participant assignment
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(summary) change tasks endpoint signature
|
||||
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
|
||||
- 🧑💻(agents) use `uv` for package management
|
||||
- ✨(summary) improve speaker-to-participant assignment
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♻(frontend) standardize role terminology across localizations
|
||||
- 🐛(backend) make start-recording atomic and fault-tolerant
|
||||
- 🔒️(frontend) room ids are generated with non-cryptographic rand
|
||||
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
|
||||
- 🐛(summary) complete webm support #1328
|
||||
- 🐛(backend) add link to "Open" text in recording email
|
||||
- 🩹(frontend) fix spacing regression in mobile control bar
|
||||
|
||||
## [1.15.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add metadata collection of VAD, connection and chat events
|
||||
- ✨(backend) introduce add-ons authentication backend
|
||||
- 💬(backend) clarify french transcription audio download link text #1299
|
||||
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
|
||||
- 🔧(backend) add setting to toggle application token exchange mechanism
|
||||
- ✨(backend) support add-ons authentication in external viewset
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(summary) support webm #1290
|
||||
- ⬆️(backend) bump django-lasuite to v0.0.26
|
||||
- 🩹(frontend) use a more standard (quality) rating scale
|
||||
- 🩹(frontend) fix access control for screen recording feature flag
|
||||
- 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
|
||||
|
||||
## [1.14.0] - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒️(helm) Add pod and container securityContext #1197
|
||||
- ✨(summary) add routes v2 for async STT and summary tasks #1171
|
||||
- ✅(backend) add unit tests for JwtTokenService #1232
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
|
||||
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
|
||||
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
|
||||
- ♻️(backend) use Authorization header for LiveKit token authentication
|
||||
- 🥅(backend) refine Twirp error handling for participant operations
|
||||
- ✨(summary) allow more file extensions #1265
|
||||
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
|
||||
- ♿️(frontend) set an explicit document title on recording download page #1261
|
||||
- ♿️(frontend) add customizable accessibility fonts #1270
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
|
||||
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
|
||||
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
|
||||
- 🔒(backend) rely on backend to allow participant update their metadata
|
||||
- 🐛(summary) fix failure webhook notification #1233
|
||||
- 🐛(summary) relax whisperX payload format #1233
|
||||
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
|
||||
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
|
||||
|
||||
## [1.13.0] - 2026-03-31
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
- ♿️(frontend) add explicit region for call controls #1216
|
||||
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
|
||||
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
|
||||
- 🐛(backend) fix regression in update-participant endpoint #1204
|
||||
|
||||
## [1.12.0] - 2026-03-24
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
|
||||
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
||||
- 💄(spinner) enforce spinner height #1183
|
||||
- 💄(custom-background) add upload indicator with preview #1183
|
||||
- ♿️(backend) improve logo accessibility in recording email notification #1092
|
||||
- ♿️(summary) improve accessibility of transcription download link #1187
|
||||
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
|
||||
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
|
||||
- ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
|
||||
- ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
|
||||
- ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) disable personal custom background while deleting #1183
|
||||
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
||||
- 🐛(frontend) fix device selection not applying during conference #1156
|
||||
|
||||
## [1.11.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) support celery with our Django backend #1124
|
||||
- ✨(helm) support ingress for custom background image #1124
|
||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
||||
- ✨(file-upload) disable by default & limit count by user #1141
|
||||
- ✨(frontend) custom background #1067
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||
- ♿️(frontend) improve chat toast a11y for screen readers #1109
|
||||
- ♿️(frontend) improve ui and aria labels for help article links #1108
|
||||
- 🌐(frontend) improve 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
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(agent) support Kyutai client for subtitle
|
||||
- ✨(all) support starting transcription and recording simultaneously
|
||||
- ✨(backend) persist options on a recording
|
||||
- ✨(all) support choosing the transcription language
|
||||
- ✨(summary) add a download link to the audio/video file
|
||||
- ✨(frontend) allow unprivileged users to request a recording
|
||||
|
||||
### Changed
|
||||
|
||||
- 🚸(frontend) remove the beta badge
|
||||
- ♻️(summary) extract file handling in a robust service
|
||||
- ♻️(all) manage recording state on the backend side
|
||||
|
||||
## [1.1.0] - 2025-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) enable user creation via email for external integrations
|
||||
- ✨(summary) add Langfuse observability for LLM API calls
|
||||
|
||||
## [1.0.1] - 2025-12-17
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) hover controls, focus, SR #803
|
||||
- ♿️(frontend) change ptt keybinding from space to v #813
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
|
||||
- ♿️(frontend) Improve focus management when opening and closing chat #807
|
||||
|
||||
@@ -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-dev
|
||||
.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-dev
|
||||
.PHONY: run-agent-multi-user-transcriber
|
||||
|
||||
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
|
||||
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
|
||||
.PHONY: run-agent-metadata-collector
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-agents
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
@@ -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-default', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
@@ -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,167 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
|
||||
# Function to update npm package version
|
||||
update_npm_version() {
|
||||
local component=$1
|
||||
print_info "Updating $component version..."
|
||||
cd "src/$component"
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
cd -
|
||||
}
|
||||
|
||||
# Function to update Python project version in pyproject.toml
|
||||
update_python_version() {
|
||||
local component=$1
|
||||
print_info "Updating $component version..."
|
||||
cd "src/$component"
|
||||
|
||||
if [ ! -f "pyproject.toml" ]; then
|
||||
print_error "pyproject.toml not found in src/$component!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q '^version = "' pyproject.toml; then
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
|
||||
rm pyproject.toml.bak
|
||||
print_info "Updated pyproject.toml version to $VERSION"
|
||||
else
|
||||
print_error "Could not find version line in pyproject.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd -
|
||||
}
|
||||
|
||||
# Check if we're in a git repository
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
print_error "Not a git repository. Please run this script from the root of your project."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if working directory is clean
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
print_error "Working directory is not clean. Please commit or stash your changes first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask user for release version number
|
||||
echo ""
|
||||
read -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||
|
||||
# Validate version format (basic semver check)
|
||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_info "Release version: $VERSION"
|
||||
|
||||
# Check if branch already exists
|
||||
BRANCH_NAME="release/$VERSION"
|
||||
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
|
||||
print_error "Branch $BRANCH_NAME already exists!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and checkout new branch
|
||||
print_info "Creating branch: $BRANCH_NAME"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
|
||||
# Update frontend
|
||||
update_npm_version "frontend"
|
||||
|
||||
# Update SDK
|
||||
update_npm_version "sdk"
|
||||
|
||||
# Update mail
|
||||
update_npm_version "mail"
|
||||
|
||||
# Update backend pyproject.toml
|
||||
update_python_version "backend"
|
||||
|
||||
# Run uv lock in backend
|
||||
print_info "Running uv lock in backend..."
|
||||
cd "src/backend"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update summary pyproject.toml
|
||||
update_python_version "summary"
|
||||
|
||||
# Update agents pyproject.toml
|
||||
update_python_version "agents"
|
||||
|
||||
# Update CHANGELOG
|
||||
print_info "Updating CHANGELOG..."
|
||||
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
print_error "CHANGELOG.md not found in project root!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current date in YYYY-MM-DD format
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Replace [Unreleased] with [version number] - YYYY-MM-DD
|
||||
if grep -q '\[Unreleased\]' CHANGELOG.md; then
|
||||
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
|
||||
|
||||
# Add new [Unreleased] section after the header
|
||||
# This adds it after the line containing "Semantic Versioning"
|
||||
sed -i.bak "/Semantic Versioning/a\\
|
||||
\\
|
||||
## [Unreleased]
|
||||
" CHANGELOG.md
|
||||
|
||||
rm CHANGELOG.md.bak
|
||||
print_info "Updated CHANGELOG.md"
|
||||
else
|
||||
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
print_info "Release preparation complete!"
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo " - Branch created: $BRANCH_NAME"
|
||||
echo " - Version updated to: $VERSION"
|
||||
echo " - Files modified:"
|
||||
echo " - src/frontend/package.json"
|
||||
echo " - src/sdk/package.json"
|
||||
echo " - src/mail/package.json"
|
||||
echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - CHANGELOG.md"
|
||||
echo ""
|
||||
print_warning "Next steps:"
|
||||
echo " 1. Review the changes: git status"
|
||||
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
|
||||
echo " 3. Push the branch: git push origin $BRANCH_NAME"
|
||||
echo ""
|
||||
@@ -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
|
||||
@@ -235,7 +237,7 @@ services:
|
||||
- livekit-egress
|
||||
|
||||
livekit-egress:
|
||||
image: livekit/egress
|
||||
image: livekit/egress:v1.11.0
|
||||
environment:
|
||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||
volumes:
|
||||
@@ -244,6 +246,41 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
- LIVEKIT_API_KEY=devkey
|
||||
- LIVEKIT_API_SECRET=secret
|
||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
||||
- AWS_S3_ACCESS_KEY_ID=meet
|
||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./src/agents
|
||||
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -100,6 +100,13 @@ sequenceDiagram
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
|
||||
|
||||
### Manual Storage Webhook
|
||||
@@ -141,3 +148,54 @@ Using default project meet
|
||||
|
||||
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
|
||||
|
||||
## Tuning recording encoding
|
||||
|
||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
||||
|
||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
||||
|
||||
### How values map to GStreamer
|
||||
|
||||
| Setting | GStreamer element | Property |
|
||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
|
||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||
|
||||
### Reference profiles
|
||||
|
||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
||||
|
||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
||||
|
||||
★ Recommended starting point for typical LaSuite Meet usage.
|
||||
|
||||
Environment variables for the **Low CPU / small file** profile:
|
||||
|
||||
```bash
|
||||
RECORDING_ENCODING_ENABLED=True
|
||||
RECORDING_ENCODING_WIDTH=1280
|
||||
RECORDING_ENCODING_HEIGHT=720
|
||||
RECORDING_ENCODING_FRAMERATE=15
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||
|
||||
@@ -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
|
||||
@@ -277,7 +277,6 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
|
||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
|
||||
@@ -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/)
|
||||
@@ -7,7 +7,7 @@ info:
|
||||
|
||||
#### Authentication Flow
|
||||
|
||||
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
|
||||
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token/`.
|
||||
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
|
||||
3. Tokens are scoped and allow applications to act on behalf of specific users.
|
||||
|
||||
@@ -21,7 +21,6 @@ info:
|
||||
|
||||
#### Upcoming Features
|
||||
|
||||
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
|
||||
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||
|
||||
@@ -40,7 +39,7 @@ tags:
|
||||
description: Room management operations
|
||||
|
||||
paths:
|
||||
/application/token:
|
||||
/application/token/:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
@@ -191,6 +190,7 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
@@ -283,7 +283,7 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: |
|
||||
JWT token obtained from the `/application/token` endpoint.
|
||||
JWT token obtained from the `/application/token/` endpoint.
|
||||
Include in requests as: `Authorization: Bearer <token>`
|
||||
|
||||
schemas:
|
||||
|
||||
@@ -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
|
||||
@@ -63,11 +66,26 @@ RECORDING_STORAGE_EVENT_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
||||
# file size and CPU load on the egress worker.
|
||||
# RECORDING_ENCODING_ENABLED=False
|
||||
# RECORDING_ENCODING_WIDTH=1280
|
||||
# RECORDING_ENCODING_HEIGHT=720
|
||||
# RECORDING_ENCODING_FRAMERATE=30
|
||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
|
||||
@@ -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=
|
||||
@@ -1,7 +1,7 @@
|
||||
APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
|
||||
@@ -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": "^14.0.0",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"office-addin-cli": "^2.0.3",
|
||||
"office-addin-debugging": "^6.0.3",
|
||||
"office-addin-dev-certs": "^2.0.3",
|
||||
"office-addin-lint": "^3.0.3",
|
||||
"office-addin-manifest": "^2.0.3",
|
||||
"office-addin-prettier-config": "^2.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.2.1"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title data-app-name></title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -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,38 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
**/.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.iml
|
||||
db.sqlite3
|
||||
.pylint.d
|
||||
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
# Env
|
||||
.env
|
||||
@@ -1,24 +1,72 @@
|
||||
FROM python:3.13-slim AS base
|
||||
FROM python:3.13.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# ---- Builder image ----
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /builder
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY pyproject.toml .
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies without the project itself (cacheable layer)
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
|
||||
# Install the project
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
|
||||
# ---- Development image ----
|
||||
FROM base AS development
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --all-extras
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
CMD ["python", "multi_user_transcriber.py", "dev"]
|
||||
|
||||
|
||||
# ---- Production image ----
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the pre-built virtualenv and application source
|
||||
COPY --from=builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Un-privileged user running the application
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
||||
|
||||
@@ -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)
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from lasuite.plugins import kyutai
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
@@ -13,14 +14,15 @@ from livekit.agents import (
|
||||
JobContext,
|
||||
JobProcess,
|
||||
JobRequest,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.agents import (
|
||||
room_io as lk_room_io,
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
load_dotenv()
|
||||
@@ -28,60 +30,26 @@ load_dotenv()
|
||||
logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
|
||||
# Default Deepgram STT configuration
|
||||
DEEPGRAM_STT_DEFAULTS = {
|
||||
"model": "nova-3",
|
||||
"language": "multi",
|
||||
}
|
||||
|
||||
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
# Use language="multi" instead for automatic multilingual support
|
||||
DEEPGRAM_STT_SUPPORTED_PARAMS = {
|
||||
"model",
|
||||
"language",
|
||||
}
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
|
||||
def _build_deepgram_stt_kwargs():
|
||||
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
|
||||
def create_stt_provider():
|
||||
"""Create STT provider based on environment configuration."""
|
||||
if STT_PROVIDER == "deepgram":
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
# Use language="multi" instead for automatic multilingual support
|
||||
_stt_instance = deepgram.STT(
|
||||
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
|
||||
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
|
||||
)
|
||||
elif STT_PROVIDER == "kyutai":
|
||||
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
||||
else:
|
||||
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||
|
||||
Only parameters supported by LiveKit's deepgram.STT() are included.
|
||||
Unsupported parameters are logged as warnings.
|
||||
"""
|
||||
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
|
||||
|
||||
# Scan environment variables for DEEPGRAM_STT_* pattern
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("DEEPGRAM_STT_"):
|
||||
# Extract parameter name and convert to lowercase
|
||||
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
|
||||
|
||||
# Check if parameter is supported by LiveKit plugin
|
||||
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
|
||||
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
|
||||
logger.warning(
|
||||
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
|
||||
f"Supported parameters: {supported}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Parse value type
|
||||
value_lower = value.lower()
|
||||
if value_lower in ("true", "false"):
|
||||
# Boolean values
|
||||
stt_kwargs[param_name] = value_lower == "true"
|
||||
elif value.isdigit():
|
||||
# Integer values
|
||||
stt_kwargs[param_name] = int(value)
|
||||
else:
|
||||
# String values
|
||||
stt_kwargs[param_name] = value
|
||||
|
||||
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
|
||||
return stt_kwargs
|
||||
return _stt_instance
|
||||
|
||||
|
||||
class Transcriber(Agent):
|
||||
@@ -89,12 +57,11 @@ class Transcriber(Agent):
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
"""Init transcription agent."""
|
||||
# Build STT configuration from environment variables
|
||||
stt_kwargs = _build_deepgram_stt_kwargs()
|
||||
stt = create_stt_provider()
|
||||
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
stt=deepgram.STT(**stt_kwargs),
|
||||
stt=stt,
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
|
||||
@@ -156,19 +123,14 @@ class MultiUserTranscriber:
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
)
|
||||
vad = self.ctx.proc.userdata.get("vad", None)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
transcription_enabled=True,
|
||||
audio_enabled=False,
|
||||
options=lk_room_io.RoomOptions(
|
||||
text_input=False, audio_output=False, text_output=True
|
||||
),
|
||||
)
|
||||
await room_io.start()
|
||||
@@ -231,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,23 +1,25 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.0.0"
|
||||
version = "1.16.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.2.18",
|
||||
"livekit-plugins-deepgram==1.2.18",
|
||||
"livekit-plugins-silero==1.2.18",
|
||||
"python-dotenv==1.2.1"
|
||||
"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.2",
|
||||
"protobuf==6.33.5",
|
||||
"minio==7.2.15"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.14.4",
|
||||
"ruff==0.15.6",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
|
||||
@@ -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,28 @@
|
||||
"""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 pydantic import ValidationError as PydanticValidationError
|
||||
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 +35,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
|
||||
@@ -111,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""Validate room configuration against the RoomConfiguration schema."""
|
||||
if value is None or value == {}:
|
||||
return value
|
||||
try:
|
||||
RoomConfiguration.model_validate(value)
|
||||
except PydanticValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -159,6 +190,8 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
configuration=configuration,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
@@ -179,6 +212,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"status",
|
||||
"mode",
|
||||
"options",
|
||||
"key",
|
||||
"is_expired",
|
||||
"expired_at",
|
||||
@@ -198,6 +232,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."""
|
||||
|
||||
@@ -210,6 +268,12 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
options = SchemaField(
|
||||
schema=RecordingOptions | None,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Recording options",
|
||||
)
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
@@ -253,6 +317,45 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
RoomConfigurationTrackSource = Literal[
|
||||
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||
]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
"""Validate room configuration structure.
|
||||
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -264,10 +367,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,
|
||||
@@ -277,6 +381,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"]
|
||||
@@ -292,12 +415,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"
|
||||
@@ -1,16 +1,29 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import decorators, mixins, pagination, throttling, viewsets
|
||||
from django_filters import rest_framework as django_filters
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
@@ -22,15 +35,22 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
@@ -51,14 +71,16 @@ from core.services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from . import permissions, serializers
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
@@ -77,20 +99,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, item):
|
||||
def __getattribute__(self, file):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, item + "s", [None])[-1]
|
||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, file + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(item)
|
||||
return super().__getattribute__(file)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of items for this view.
|
||||
Get the list of files for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
@@ -191,18 +213,6 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
@@ -213,6 +223,7 @@ class RoomViewSet(
|
||||
API endpoints to access and perform actions on rooms.
|
||||
"""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.RoomPermissions]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
@@ -308,14 +319,30 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(room=room, mode=mode)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
)
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
# pass that check and the DB-level UNIQUE constraint catches the loser.
|
||||
return drf_response.Response(
|
||||
{"error": f"A recording is already in progress for room {room.slug}"},
|
||||
status=drf_status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
@@ -323,11 +350,23 @@ class RoomViewSet(
|
||||
try:
|
||||
worker_manager.start(recording)
|
||||
except RecordingStartError:
|
||||
models.Recording.objects.filter(pk=recording.pk).update(
|
||||
status=models.RecordingStatusChoices.FAILED_TO_START
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status=drf_status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and (
|
||||
recording.options.get("collect_metadata", False)
|
||||
):
|
||||
try:
|
||||
MetadataCollectorService().start(recording)
|
||||
logger.debug("Started MetadataCollectorService")
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to start MetadataCollectorService")
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording successfully started for room {room.slug}"},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
@@ -376,7 +415,10 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
throttle_classes=[
|
||||
throttling.RequestEntryAuthenticatedUserRateThrottle,
|
||||
throttling.RequestEntryAnonRateThrottle,
|
||||
],
|
||||
)
|
||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Request entry to a room"""
|
||||
@@ -477,16 +519,14 @@ class RoomViewSet(
|
||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
raise e
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "error", "message": str(e)}, status=status_code
|
||||
)
|
||||
return drf_response.Response({"status": "error"}, status=status_code)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
|
||||
)
|
||||
def creation_callback(self, request):
|
||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||
@@ -589,6 +629,11 @@ class RoomViewSet(
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
track_sid=serializer.validated_data["track_sid"],
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to mute participant"},
|
||||
@@ -616,15 +661,22 @@ class RoomViewSet(
|
||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
permission = serializer.validated_data.get("permission")
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
metadata=serializer.validated_data.get("metadata"),
|
||||
attributes=serializer.validated_data.get("attributes"),
|
||||
permission=serializer.validated_data.get("permission"),
|
||||
permission=permission.model_dump() if permission else None,
|
||||
name=serializer.validated_data.get("name"),
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to update participant"},
|
||||
@@ -657,6 +709,11 @@ class RoomViewSet(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to remove participant"},
|
||||
@@ -667,6 +724,92 @@ class RoomViewSet(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="toggle-hand",
|
||||
url_name="toggle-hand",
|
||||
permission_classes=[permissions.HasLiveKitRoomAccess],
|
||||
authentication_classes=[LiveKitTokenAuthentication],
|
||||
)
|
||||
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Raise or lower the current participant's hand in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.RaiseHandSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
identity = request.auth.identity
|
||||
|
||||
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
|
||||
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
|
||||
# means the hand is raised. The timestamp is used by clients to determine
|
||||
# the order in which participants raised their hands.
|
||||
hand_raised_at = (
|
||||
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
|
||||
)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=identity,
|
||||
attributes={"handRaisedAt": hand_raised_at},
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to update participant hand state"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="rename",
|
||||
url_name="rename",
|
||||
permission_classes=[permissions.HasLiveKitRoomAccess],
|
||||
authentication_classes=[LiveKitTokenAuthentication],
|
||||
)
|
||||
def rename(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Rename the current participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
identity = request.auth.identity
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=identity,
|
||||
name=serializer.validated_data["name"],
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to rename participant"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
@@ -744,14 +887,19 @@ class RecordingViewSet(
|
||||
recording_id = parser.get_recording_id(request.data)
|
||||
|
||||
except ParsingEventDataError as e:
|
||||
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
||||
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
||||
|
||||
except InvalidBucketError as e:
|
||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
||||
|
||||
except InvalidFileTypeError as e:
|
||||
except InvalidFilepathError:
|
||||
return drf_response.Response(
|
||||
{"message": f"Ignore this file type, {e}"},
|
||||
{"message": "Notification ignored."},
|
||||
)
|
||||
|
||||
except InvalidFileTypeError:
|
||||
return drf_response.Response(
|
||||
{"message": "Notification ignored."},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -798,7 +946,7 @@ class RecordingViewSet(
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
@@ -815,7 +963,7 @@ class RecordingViewSet(
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
@@ -839,7 +987,7 @@ class RecordingViewSet(
|
||||
recording_id = url_params["recording_id"]
|
||||
|
||||
extension = url_params["extension"]
|
||||
if extension not in [item.value for item in FileExtension]:
|
||||
if extension not in [file.value for file in FileExtension]:
|
||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||
|
||||
try:
|
||||
@@ -863,3 +1011,324 @@ class RecordingViewSet(
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class FileViewSet(
|
||||
SerializerPerActionMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
FileViewSet API.
|
||||
|
||||
This viewset provides CRUD operations and additional actions for managing files.
|
||||
|
||||
### API Endpoints:
|
||||
1. **List**: Retrieve a paginated list of files.
|
||||
Example: GET /files/?page=2
|
||||
2. **Retrieve**: Get a specific file by its ID.
|
||||
Example: GET /files/{id}/
|
||||
3. **Create**: Create a new file.
|
||||
Example: POST /files/
|
||||
4. **Update**: Update a file by its ID.
|
||||
Example: PUT /files/{id}/
|
||||
5. **Delete**: Soft delete a file by its ID.
|
||||
Example: DELETE /files/{id}/
|
||||
|
||||
|
||||
### Ordering: created_at, updated_at, title
|
||||
|
||||
Example:
|
||||
- Ascending: GET /api/v1.0/files/?ordering=created_at
|
||||
|
||||
### Filtering:
|
||||
- `is_creator_me=true`: Returns files created by the current user.
|
||||
- `is_creator_me=false`: Returns files created by other users.
|
||||
- `is_deleted=false`: Returns files that are not (soft) deleted
|
||||
|
||||
Example:
|
||||
- GET /api/v1.0/files/?is_creator_me=true
|
||||
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
|
||||
|
||||
### Notes:
|
||||
- Implements soft delete logic to retain file
|
||||
"""
|
||||
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
permissions.FilePermission,
|
||||
]
|
||||
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
|
||||
default_serializer_class = serializers.FileSerializer
|
||||
serializer_classes = {
|
||||
"list": serializers.ListFileSerializer,
|
||||
"create": serializers.CreateFileSerializer,
|
||||
}
|
||||
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
|
||||
filterset_class = ListFileFilter
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset that defaults to the current request user."""
|
||||
user = self.request.user
|
||||
queryset = super().get_queryset().select_related("creator")
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset.none()
|
||||
|
||||
# For now, we force the filtering on the current user in all cases, might evolve later
|
||||
queryset = queryset.filter(creator=user)
|
||||
return queryset
|
||||
|
||||
def get_response_for_queryset(self, queryset, context=None):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
context = context or self.get_serializer_context()
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True, context=context)
|
||||
result = self.get_paginated_response(serializer.data)
|
||||
return result
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True, context=context)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as creator of the newly created file."""
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
file_type = serializer.validated_data["type"]
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
|
||||
|
||||
count = models.File.objects.filter(
|
||||
creator=self.request.user,
|
||||
deleted_at__isnull=True,
|
||||
type=file_type,
|
||||
).count()
|
||||
|
||||
if count >= config_for_file_type["max_count_by_user"]:
|
||||
logger.info(
|
||||
"create_item: user reached max files per user for type %s",
|
||||
file_type,
|
||||
)
|
||||
raise serializers.PermissionDenied(
|
||||
_("You have reached the maximum number of files for this type.")
|
||||
)
|
||||
|
||||
serializer.save(creator=self.request.user)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
||||
instance.soft_delete()
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||
@FeatureFlag.require("file_upload")
|
||||
def upload_ended(self, request, *args, **kwargs):
|
||||
"""
|
||||
Check the actual uploaded file and mark it as ready.
|
||||
"""
|
||||
|
||||
file = self.get_object()
|
||||
|
||||
if not file.is_pending_upload:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"file": "This action is only available for files in PENDING state."},
|
||||
code="file_upload_state_not_pending",
|
||||
)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
head_response = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)
|
||||
file_size = head_response["ContentLength"]
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
if file_size > config_for_file_type["max_size"]:
|
||||
self._complete_file_deletion(file)
|
||||
logger.info(
|
||||
"upload_ended: file size (%s) for file %s higher than the allowed max size",
|
||||
file_size,
|
||||
file.file_key,
|
||||
)
|
||||
raise drf_exceptions.ValidationError(
|
||||
detail="The file size is higher than the allowed max size.",
|
||||
code="file_size_exceeded",
|
||||
)
|
||||
|
||||
# python-magic recommends using at least the first 2048 bytes
|
||||
# to reduce incorrect identification.
|
||||
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
|
||||
# of the file for mime type identification.
|
||||
if file_size > 2048:
|
||||
range_response = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
Range="bytes=0-2047",
|
||||
)
|
||||
file_head = range_response["Body"].read()
|
||||
else:
|
||||
file_head = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)["Body"].read()
|
||||
|
||||
# Use improved MIME type detection combining magic bytes and file extension
|
||||
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
|
||||
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
|
||||
if mimetype not in allowed_file_mimetypes:
|
||||
self._complete_file_deletion(file)
|
||||
logger.warning(
|
||||
"upload_ended: mimetype not allowed %s for file %s",
|
||||
mimetype,
|
||||
file.file_key,
|
||||
)
|
||||
raise drf_exceptions.ValidationError(
|
||||
detail="The file type is not allowed.",
|
||||
code="file_type_not_allowed",
|
||||
)
|
||||
|
||||
file.upload_state = models.FileUploadStateChoices.READY
|
||||
file.mimetype = mimetype
|
||||
file.size = file_size
|
||||
|
||||
file.save(update_fields=["upload_state", "mimetype", "size"])
|
||||
|
||||
if head_response["ContentType"] != mimetype:
|
||||
logger.info(
|
||||
"upload_ended: content type mismatch between object storage and file,"
|
||||
" updating from %s to %s",
|
||||
head_response["ContentType"],
|
||||
mimetype,
|
||||
)
|
||||
s3_client.copy_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
CopySource={
|
||||
"Bucket": default_storage.bucket_name,
|
||||
"Key": file.file_key,
|
||||
},
|
||||
ContentType=mimetype,
|
||||
Metadata=head_response["Metadata"],
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
|
||||
# Not yet implemented
|
||||
# Change the file.upload_state when this will be done
|
||||
# malware_detection.analyse_file(file.file_key, file_id=file.id)
|
||||
|
||||
serializer = self.get_serializer(file)
|
||||
|
||||
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
|
||||
|
||||
def _complete_file_deletion(self, file):
|
||||
"""Delete a file completely."""
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
process_file_deletion.delay(file.id)
|
||||
|
||||
def _authorize_subrequest(self, request, pattern):
|
||||
"""
|
||||
Authorize access based on the original URL of an Nginx subrequest
|
||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
|
||||
Parameters:
|
||||
- pattern: The regex pattern to extract identifiers from the URL.
|
||||
|
||||
Returns:
|
||||
- A dictionary of URL parameters if the request is authorized.
|
||||
Raises:
|
||||
- PermissionDenied if authorization fails.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
parsed_url = urlparse(original_url)
|
||||
match = pattern.search(unquote(parsed_url.path))
|
||||
|
||||
if not match:
|
||||
logger.warning(
|
||||
"Subrequest URL '%s' did not match pattern '%s'",
|
||||
parsed_url.path,
|
||||
pattern,
|
||||
)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
try:
|
||||
url_params = match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
pk = url_params.get("pk")
|
||||
if not pk:
|
||||
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Fetch the file and check if the user has access
|
||||
queryset = models.File.objects.all()
|
||||
# No suspicious analysis implemented yet
|
||||
# queryset = self._filter_suspicious_files(queryset, request.user)
|
||||
try:
|
||||
file = queryset.get(pk=pk)
|
||||
except models.File.DoesNotExist as exc:
|
||||
logger.warning("File with ID '%s' does not exist", pk)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
user_abilities = file.get_abilities(request.user)
|
||||
if not user_abilities.get(self.action, False):
|
||||
logger.warning(
|
||||
"User '%s' lacks permission for file '%s'", request.user.id, pk
|
||||
)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug(
|
||||
"Subrequest authorization successful. Extracted parameters: %s", url_params
|
||||
)
|
||||
return url_params, request.user.id, file
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
@FeatureFlag.require("file_upload")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to an file's
|
||||
attachment file.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
url_params, _, file = self._authorize_subrequest(
|
||||
request, MEDIA_STORAGE_URL_PATTERN
|
||||
)
|
||||
|
||||
if file.is_pending_upload:
|
||||
logger.warning("File '%s' is not ready", file.id)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -14,10 +14,19 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
|
||||
def authenticate(self, request):
|
||||
token = request.data.get("token")
|
||||
if not token:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return None # No authentication attempted
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise exceptions.AuthenticationFailed(
|
||||
"Authorization header must be: Bearer <token>"
|
||||
)
|
||||
|
||||
token = parts[1]
|
||||
|
||||
try:
|
||||
verifier = TokenVerifier(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
|
||||
@@ -14,9 +14,15 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
)
|
||||
|
||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}"
|
||||
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
|
||||
)
|
||||
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
|
||||