mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
394 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f29c5d35d | |||
| 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 | |||
| 98e568d63c | |||
| 97e1f7f53f | |||
| 6022809888 | |||
| d241de6af1 | |||
| ad494f5de5 | |||
| fba879e739 | |||
| cac5595a91 | |||
| 78e5c72310 | |||
| 2ab31189f4 | |||
| bb4a863f8d | |||
| 0241f67787 | |||
| 908bbb828a | |||
| c7f5dabbad | |||
| a642c6d9a2 | |||
| a6dc12d91c | |||
| 307987d94d | |||
| d7ebdbf401 | |||
| dad396273c | |||
| 555daedeba | |||
| 0d09d1df08 | |||
| a40af726b6 | |||
| f8a37e55b1 | |||
| 3baec0a863 | |||
| 5b6ed6bbf0 | |||
| aea01636cf | |||
| e4c2b42e4a | |||
| 36ba0f9c8e | |||
| 2d6fe6ee7d | |||
| e2fcf7dd2c | |||
| 9f9cef7e2a | |||
| b403ac56bf | |||
| baf378d53d | |||
| 990507e3c7 | |||
| 6cd54f7e1e | |||
| 315d48a501 | |||
| 2f7b56f918 | |||
| 53e68b7780 | |||
| 10eda5c2ea | |||
| ba3b3fe0ba | |||
| 0c3bcd81c9 | |||
| dbc66c2f07 | |||
| 39be4697b0 | |||
| 2443fa63a5 | |||
| 214dc87b1f | |||
| 3dc23be101 | |||
| 6b5e8081bc | |||
| df671ea994 | |||
| 06a5b9b17e | |||
| 59d4c2583b | |||
| 4b80b4ac9f | |||
| 96d7a8875b | |||
| dc177b69d8 | |||
| 36b2156c7b | |||
| ec94d613fa | |||
| 70d9d55227 | |||
| 5c74ace0d8 | |||
| f0939b6f7c | |||
| aecc48f928 | |||
| 4353db4a5f |
+8
-6
@@ -4,7 +4,7 @@ __pycache__
|
|||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
venv
|
venv
|
||||||
.venv
|
**/.venv
|
||||||
|
|
||||||
# System-specific files
|
# System-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -24,13 +24,15 @@ data
|
|||||||
.cache
|
.cache
|
||||||
.circleci
|
.circleci
|
||||||
.git
|
.git
|
||||||
.vscode
|
|
||||||
.iml
|
.iml
|
||||||
.idea
|
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
.mypy_cache
|
|
||||||
.pylint.d
|
.pylint.d
|
||||||
.pytest_cache
|
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
node_modules
|
**/node_modules
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Crowdin files
|
- name: Download Crowdin files
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
|
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||||
|
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
@@ -21,13 +23,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
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
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: lasuite/meet-backend
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -35,18 +43,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '--target backend-production -f Dockerfile'
|
# docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -57,13 +66,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
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
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: lasuite/meet-frontend
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -71,12 +86,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -84,6 +99,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -94,13 +110,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
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
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: lasuite/meet-frontend-dinum
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -108,12 +130,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -121,6 +143,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
file: ./docker/dinum-frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -131,13 +154,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
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
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: lasuite/meet-summary
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -145,6 +174,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
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
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -152,6 +189,7 @@ jobs:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
file: ./src/summary/Dockerfile
|
file: ./src/summary/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -162,7 +200,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
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
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -176,6 +220,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
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
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -183,6 +235,7 @@ jobs:
|
|||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
file: ./src/agents/Dockerfile
|
file: ./src/agents/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
+86
-30
@@ -7,14 +7,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- "*"
|
- "*"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-git:
|
lint-git:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
@@ -34,22 +38,54 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||||
|
|
||||||
|
check-changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||||
|
github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
|
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||||
|
|
||||||
|
lint-changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
- name: Check CHANGELOG max line length
|
||||||
|
run: |
|
||||||
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
|
if [ $max_line_length -ge 80 ]; then
|
||||||
|
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
build-mails:
|
build-mails:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/mail
|
working-directory: src/mail
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -69,43 +105,49 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache mail templates
|
- name: Cache mail templates
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
lint-back:
|
lint-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
- name: Install uv
|
||||||
- name: Install development dependencies
|
uses: astral-sh/setup-uv@v7
|
||||||
run: pip install --user .[dev]
|
- name: Install the project
|
||||||
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: ~/.local/bin/ruff format . --diff
|
run: uv run ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: ~/.local/bin/ruff check .
|
run: uv run ruff check .
|
||||||
- name: Lint code with pylint
|
- name: Lint code with pylint
|
||||||
run: ~/.local/bin/pylint meet demo core
|
run: uv run pylint meet demo core
|
||||||
|
|
||||||
lint-agents:
|
lint-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/agents
|
working-directory: src/agents
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -118,14 +160,16 @@ jobs:
|
|||||||
|
|
||||||
lint-summary:
|
lint-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -139,7 +183,8 @@ jobs:
|
|||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-mails
|
needs: build-mails
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
@@ -183,10 +228,15 @@ jobs:
|
|||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
|
OIDC_RS_CLIENT_ID: meet
|
||||||
|
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||||
|
OIDC_OP_URL: https://oidc.example.com
|
||||||
|
MEDIA_BASE_URL: http://localhost:8083
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -194,7 +244,7 @@ jobs:
|
|||||||
sudo mkdir -p /data/static
|
sudo mkdir -p /data/static
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -228,13 +278,13 @@ jobs:
|
|||||||
mc mb meet/meet-media-storage"
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install development dependencies
|
- name: Install the dependencies
|
||||||
run: pip install --user .[dev]
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
- name: Install gettext (required to compile messages)
|
- name: Install gettext (required to compile messages)
|
||||||
run: |
|
run: |
|
||||||
@@ -242,16 +292,18 @@ jobs:
|
|||||||
sudo apt-get install -y gettext
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
- name: Generate a MO file from strings extracted from the project
|
||||||
run: python manage.py compilemessages
|
run: uv run python manage.py compilemessages
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: ~/.local/bin/pytest -n 2
|
run: uv run pytest -n 2
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci
|
run: cd src/frontend/ && npm ci
|
||||||
@@ -264,12 +316,14 @@ jobs:
|
|||||||
|
|
||||||
lint-sdk:
|
lint-sdk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -282,13 +336,15 @@ jobs:
|
|||||||
|
|
||||||
build-sdk:
|
build-sdk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
needs: lint-sdk
|
needs: lint-sdk
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
# Translations # Translations
|
||||||
*.pot
|
*.pot
|
||||||
|
*.mo
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
|||||||
+218
-2
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
@@ -9,4 +8,221 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- 🔧(backend) support `_FILE` for secret environment variables #566
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
+45
-24
@@ -4,7 +4,7 @@
|
|||||||
FROM python:3.13.5-alpine3.21 AS base
|
FROM python:3.13.5-alpine3.21 AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# 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
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
@@ -13,14 +13,28 @@ RUN apk update && \
|
|||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base AS back-builder
|
||||||
|
|
||||||
WORKDIR /builder
|
|
||||||
|
|
||||||
# Copy required python dependencies
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
COPY ./src/backend /builder
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
RUN mkdir /install && \
|
# Disable Python downloads, because we want to use the system interpreter
|
||||||
pip install --prefix=/install .
|
# 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 ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:20 AS mail-builder
|
||||||
@@ -30,7 +44,7 @@ COPY ./src/mail /mail/app
|
|||||||
WORKDIR /mail/app
|
WORKDIR /mail/app
|
||||||
|
|
||||||
RUN yarn install --frozen-lockfile && \
|
RUN yarn install --frozen-lockfile && \
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
@@ -39,19 +53,20 @@ ARG MEET_STATIC_ROOT=/data/static
|
|||||||
|
|
||||||
RUN apk add \
|
RUN apk add \
|
||||||
pango \
|
pango \
|
||||||
|
libmagic \
|
||||||
rdfind
|
rdfind
|
||||||
|
|
||||||
# Copy installed python dependencies
|
|
||||||
COPY --from=back-builder /install /usr/local
|
|
||||||
|
|
||||||
# Copy Meet application (see .dockerignore)
|
|
||||||
COPY ./src/backend /app/
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the application from the builder
|
||||||
|
COPY --from=back-builder /app /app
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
|
||||||
# collectstatic
|
# collectstatic
|
||||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||||
# final image
|
# final image
|
||||||
@@ -68,6 +83,7 @@ RUN apk --no-cache add \
|
|||||||
gettext \
|
gettext \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
pango \
|
pango \
|
||||||
|
libmagic \
|
||||||
shared-mime-info
|
shared-mime-info
|
||||||
|
|
||||||
|
|
||||||
@@ -79,14 +95,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
|||||||
# docker user (see entrypoint).
|
# docker user (see entrypoint).
|
||||||
RUN chmod g=u /etc/passwd
|
RUN chmod g=u /etc/passwd
|
||||||
|
|
||||||
# Copy installed python dependencies
|
# Copy the application from the builder
|
||||||
COPY --from=back-builder /install /usr/local
|
COPY --from=back-builder /app /app
|
||||||
|
|
||||||
# Copy Meet application (see .dockerignore)
|
|
||||||
COPY ./src/backend /app/
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Generate compiled translation messages
|
||||||
|
RUN DJANGO_CONFIGURATION=Build \
|
||||||
|
python manage.py compilemessages --ignore=".venv/**/*"
|
||||||
|
|
||||||
# We wrap commands run in this container by the following entrypoint that
|
# We wrap commands run in this container by the following entrypoint that
|
||||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||||
# ID.
|
# ID.
|
||||||
@@ -101,10 +120,9 @@ USER root:root
|
|||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apk add postgresql-client
|
||||||
|
|
||||||
# Uninstall Meet and re-install it in editable mode along with development
|
# Install development dependencies
|
||||||
# dependencies
|
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
|
||||||
RUN pip uninstall -y meet
|
uv sync --all-extras --locked
|
||||||
RUN pip install -e .[dev]
|
|
||||||
|
|
||||||
# Restore the un-privileged user running the application
|
# Restore the un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
@@ -113,7 +131,7 @@ USER ${DOCKER_USER}
|
|||||||
# Target database host (e.g. database engine following docker compose services
|
# Target database host (e.g. database engine following docker compose services
|
||||||
# name) & port
|
# name) & port
|
||||||
ENV DB_HOST=postgresql \
|
ENV DB_HOST=postgresql \
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Run django development server
|
# Run django development server
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
@@ -127,6 +145,9 @@ ARG MEET_STATIC_ROOT=/data/static
|
|||||||
RUN mkdir -p /usr/local/etc/gunicorn
|
RUN mkdir -p /usr/local/etc/gunicorn
|
||||||
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
|
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
|
# Un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
USER ${DOCKER_USER}
|
USER ${DOCKER_USER}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# VARIABLES
|
# VARIABLES
|
||||||
|
|
||||||
BOLD := \033[1m
|
ESC := $(shell printf '\033')
|
||||||
RESET := \033[0m
|
BOLD := $(ESC)[1m
|
||||||
GREEN := \033[1;32m
|
RESET := $(ESC)[0m
|
||||||
|
GREEN := $(ESC)[1;32m
|
||||||
|
|
||||||
|
|
||||||
# -- Database
|
# -- Database
|
||||||
@@ -85,7 +86,8 @@ bootstrap: \
|
|||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build
|
mails-build \
|
||||||
|
run
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
@@ -112,7 +114,8 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
|
|
||||||
run-backend: ## start only the backend application and all needed services
|
run-backend: ## start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||||
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
@echo "Wait for postgresql to be up..."
|
@echo "Wait for postgresql to be up..."
|
||||||
@$(WAIT_DB)
|
@$(WAIT_DB)
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
@@ -220,7 +223,7 @@ superuser: ## Create an admin superuser with password "admin"
|
|||||||
.PHONY: superuser
|
.PHONY: superuser
|
||||||
|
|
||||||
back-i18n-compile: ## compile the gettext files
|
back-i18n-compile: ## compile the gettext files
|
||||||
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||||
.PHONY: back-i18n-compile
|
.PHONY: back-i18n-compile
|
||||||
|
|
||||||
back-i18n-generate: ## create the .pot files used for i18n
|
back-i18n-generate: ## create the .pot files used for i18n
|
||||||
@@ -351,13 +354,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
|||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt: ## start the kubernetes cluster using kind
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
tilt up -f ./bin/Tiltfile
|
tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
||||||
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
|
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.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!
|
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
|
## 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.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [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
|
## Contributing
|
||||||
|
|||||||
+5
-1
@@ -70,7 +70,7 @@ docker_build(
|
|||||||
'localhost:5001/meet-agents:latest',
|
'localhost:5001/meet-agents:latest',
|
||||||
context='../src/agents',
|
context='../src/agents',
|
||||||
dockerfile='../src/agents/Dockerfile',
|
dockerfile='../src/agents/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'production',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../src/agents', '/app'),
|
sync('../src/agents', '/app'),
|
||||||
@@ -99,7 +99,11 @@ k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .
|
|||||||
|
|
||||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||||
|
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||||
|
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||||
|
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||||
|
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||||
|
|||||||
Executable
+9
@@ -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
|
||||||
Executable
+50
@@ -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
|
||||||
Executable
+15
@@ -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 $$
|
||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Function to update npm package version
|
||||||
|
update_npm_version() {
|
||||||
|
local component=$1
|
||||||
|
print_info "Updating $component version..."
|
||||||
|
cd "src/$component"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
cd -
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to update Python project version in pyproject.toml
|
||||||
|
update_python_version() {
|
||||||
|
local component=$1
|
||||||
|
print_info "Updating $component version..."
|
||||||
|
cd "src/$component"
|
||||||
|
|
||||||
|
if [ ! -f "pyproject.toml" ]; then
|
||||||
|
print_error "pyproject.toml not found in src/$component!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q '^version = "' pyproject.toml; then
|
||||||
|
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
|
||||||
|
rm pyproject.toml.bak
|
||||||
|
print_info "Updated pyproject.toml version to $VERSION"
|
||||||
|
else
|
||||||
|
print_error "Could not find version line in pyproject.toml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd -
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we're in a git repository
|
||||||
|
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||||
|
print_error "Not a git repository. Please run this script from the root of your project."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if working directory is clean
|
||||||
|
if ! git diff-index --quiet HEAD --; then
|
||||||
|
print_error "Working directory is not clean. Please commit or stash your changes first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask user for release version number
|
||||||
|
echo ""
|
||||||
|
read -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||||
|
|
||||||
|
# Validate version format (basic semver check)
|
||||||
|
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "Release version: $VERSION"
|
||||||
|
|
||||||
|
# Check if branch already exists
|
||||||
|
BRANCH_NAME="release/$VERSION"
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
|
||||||
|
print_error "Branch $BRANCH_NAME already exists!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create and checkout new branch
|
||||||
|
print_info "Creating branch: $BRANCH_NAME"
|
||||||
|
git checkout -b "$BRANCH_NAME"
|
||||||
|
|
||||||
|
# Update frontend
|
||||||
|
update_npm_version "frontend"
|
||||||
|
|
||||||
|
# Update SDK
|
||||||
|
update_npm_version "sdk"
|
||||||
|
|
||||||
|
# Update mail
|
||||||
|
update_npm_version "mail"
|
||||||
|
|
||||||
|
# Update backend pyproject.toml
|
||||||
|
update_python_version "backend"
|
||||||
|
|
||||||
|
# Update summary pyproject.toml
|
||||||
|
update_python_version "summary"
|
||||||
|
|
||||||
|
# Update agents pyproject.toml
|
||||||
|
update_python_version "agents"
|
||||||
|
|
||||||
|
# Update CHANGELOG
|
||||||
|
print_info "Updating CHANGELOG..."
|
||||||
|
|
||||||
|
if [ ! -f "CHANGELOG.md" ]; then
|
||||||
|
print_error "CHANGELOG.md not found in project root!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current date in YYYY-MM-DD format
|
||||||
|
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
# Replace [Unreleased] with [version number] - YYYY-MM-DD
|
||||||
|
if grep -q '\[Unreleased\]' CHANGELOG.md; then
|
||||||
|
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
|
||||||
|
|
||||||
|
# Add new [Unreleased] section after the header
|
||||||
|
# This adds it after the line containing "Semantic Versioning"
|
||||||
|
sed -i.bak "/Semantic Versioning/a\\
|
||||||
|
\\
|
||||||
|
## [Unreleased]
|
||||||
|
" CHANGELOG.md
|
||||||
|
|
||||||
|
rm CHANGELOG.md.bak
|
||||||
|
print_info "Updated CHANGELOG.md"
|
||||||
|
else
|
||||||
|
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
print_info "Release preparation complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " - Branch created: $BRANCH_NAME"
|
||||||
|
echo " - Version updated to: $VERSION"
|
||||||
|
echo " - Files modified:"
|
||||||
|
echo " - src/frontend/package.json"
|
||||||
|
echo " - src/sdk/package.json"
|
||||||
|
echo " - src/mail/package.json"
|
||||||
|
echo " - src/backend/pyproject.toml"
|
||||||
|
echo " - src/summary/pyproject.toml"
|
||||||
|
echo " - src/agents/pyproject.toml"
|
||||||
|
echo " - CHANGELOG.md"
|
||||||
|
echo ""
|
||||||
|
print_warning "Next steps:"
|
||||||
|
echo " 1. Review the changes: git status"
|
||||||
|
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
|
||||||
|
echo " 3. Push the branch: git push origin $BRANCH_NAME"
|
||||||
|
echo ""
|
||||||
+15
-3
@@ -58,7 +58,7 @@ services:
|
|||||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||||
/usr/bin/mc admin service restart meet --wait --json &&
|
/usr/bin/mc admin service restart meet --wait --json &&
|
||||||
sleep 15 &&
|
sleep 15 &&
|
||||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
|
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
||||||
exit 0;"
|
exit 0;"
|
||||||
|
|
||||||
app-dev:
|
app-dev:
|
||||||
@@ -80,16 +80,19 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
|
- /app/.venv
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
- postgresql
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
- nginx
|
|
||||||
- livekit
|
- livekit
|
||||||
- createbuckets
|
- createbuckets
|
||||||
- createwebhook
|
- createwebhook
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "127.0.0.1.nip.io:host-gateway"
|
- "127.0.0.1.nip.io:host-gateway"
|
||||||
|
networks:
|
||||||
|
- resource-server
|
||||||
|
- default
|
||||||
|
|
||||||
celery-dev:
|
celery-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
@@ -103,6 +106,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
|
- /app/.venv
|
||||||
depends_on:
|
depends_on:
|
||||||
- app-dev
|
- app-dev
|
||||||
|
|
||||||
@@ -145,6 +149,10 @@ services:
|
|||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- keycloak
|
- keycloak
|
||||||
|
- app-dev
|
||||||
|
networks:
|
||||||
|
- resource-server
|
||||||
|
- default
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
@@ -229,7 +237,7 @@ services:
|
|||||||
- livekit-egress
|
- livekit-egress
|
||||||
|
|
||||||
livekit-egress:
|
livekit-egress:
|
||||||
image: livekit/egress
|
image: livekit/egress:v1.11.0
|
||||||
environment:
|
environment:
|
||||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||||
volumes:
|
volumes:
|
||||||
@@ -298,3 +306,7 @@ services:
|
|||||||
watch:
|
watch:
|
||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: ./src/summary
|
path: ./src/summary
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
resource-server:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ERB templated nginx configuration
|
||||||
|
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
|
||||||
|
|
||||||
|
upstream backend_server {
|
||||||
|
server localhost:8000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen <%= ENV["PORT"] %>;
|
||||||
|
server_name _;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
root /app/build/frontend-out;
|
||||||
|
|
||||||
|
# Django rest framework
|
||||||
|
location ^~ /api/ {
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://backend_server;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Django admin
|
||||||
|
location ^~ /admin/ {
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://backend_server;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve static files with caching
|
||||||
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
# Add no-cache headers
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
|
||||||
|
add_header Expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optionally, handle 404 errors by redirecting to index.html
|
||||||
|
error_page 404 =200 /index.html;
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ RUN npm run build
|
|||||||
# Inject PostHog sourcemap metadata into the built assets
|
# Inject PostHog sourcemap metadata into the built assets
|
||||||
# This metadata is essential for correctly mapping errors to source maps in production
|
# This metadata is essential for correctly mapping errors to source maps in production
|
||||||
RUN set -e && \
|
RUN set -e && \
|
||||||
npx @posthog/cli sourcemap inject --directory ./dist/assets
|
npx @posthog/cli@0.4.8 sourcemap inject --directory ./dist/assets
|
||||||
|
|
||||||
COPY ./docker/dinum-frontend/dinum-styles.css \
|
COPY ./docker/dinum-frontend/dinum-styles.css \
|
||||||
./dist/assets/
|
./dist/assets/
|
||||||
@@ -42,7 +42,12 @@ COPY ./docker/dinum-frontend/fonts/ \
|
|||||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
|
RUN apk update && apk upgrade libssl3 \
|
||||||
|
libcrypto3 \
|
||||||
|
libxml2>=2.12.7-r2 \
|
||||||
|
libxslt>=1.1.39-r2 \
|
||||||
|
libexpat>=2.7.2-r0 \
|
||||||
|
libpng>=1.6.53-r0
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,47 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
# Proxy auth for media
|
||||||
|
location /media/ {
|
||||||
|
# Auth request configuration
|
||||||
|
auth_request /media-auth;
|
||||||
|
auth_request_set $authHeader $upstream_http_authorization;
|
||||||
|
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||||
|
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||||
|
|
||||||
|
# Pass specific headers from the auth response
|
||||||
|
proxy_set_header Authorization $authHeader;
|
||||||
|
proxy_set_header X-Amz-Date $authDate;
|
||||||
|
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||||
|
|
||||||
|
# Get resource from Minio
|
||||||
|
proxy_pass http://minio:9000/meet-media-storage/;
|
||||||
|
proxy_set_header Host minio:9000;
|
||||||
|
# To use with ds_proxy
|
||||||
|
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
||||||
|
# proxy_set_header Host ds-proxy:4444;
|
||||||
|
add_header Content-Disposition "attachment";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media-auth {
|
||||||
|
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
|
||||||
|
# Prevent the body from being passed
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Original-Method $request_method;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://keycloak:8080;
|
proxy_pass http://keycloak:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
upstream meet_backend {
|
||||||
|
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream meet_frontend {
|
||||||
|
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8083;
|
||||||
|
server_name localhost;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# Disables server version feedback on pages and in headers
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
|
||||||
|
location @proxy_to_meet_backend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://meet_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @proxy_to_meet_frontend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://meet_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri @proxy_to_meet_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
try_files $uri @proxy_to_meet_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin {
|
||||||
|
try_files $uri @proxy_to_meet_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
try_files $uri @proxy_to_meet_backend;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||||
COPY rootCA.pem /etc/ssl/certs/
|
COPY rootCA.pem /etc/ssl/certs/
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ redis:
|
|||||||
address: redis:6379
|
address: redis:6379
|
||||||
keys:
|
keys:
|
||||||
devkey: secret
|
devkey: secret
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
api_key: devkey
|
||||||
|
urls:
|
||||||
|
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
# You can add any necessary service here that will join the same docker network
|
||||||
|
# sharing keycloak. Services added to the 'meet_resource-server' network will be
|
||||||
|
# able to communicate with keycloak and the backend on that network.
|
||||||
|
services:
|
||||||
|
# busybox service is only used for testing purposes. It provides curl to test
|
||||||
|
# connectivity to the backend and keycloak services. Replace this with your
|
||||||
|
# relevant application services that need to communicate with keycloak.
|
||||||
|
busybox:
|
||||||
|
image: alpine:latest
|
||||||
|
privileged: true
|
||||||
|
command: sh -c "apk add --no-cache curl && sleep infinity"
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- meet_resource-server
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
meet_resource-server:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/postgresql
|
||||||
|
- env.d/common
|
||||||
|
volumes:
|
||||||
|
- ./data/databases/backend:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: lasuite/meet-backend:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- env.d/common
|
||||||
|
- env.d/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
livekit:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: lasuite/meet-frontend:latest
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
entrypoint:
|
||||||
|
- /docker-entrypoint.sh
|
||||||
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- env.d/common
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
livekit:
|
||||||
|
image: livekit/livekit-server:latest
|
||||||
|
command: --config /config.yaml
|
||||||
|
ports:
|
||||||
|
- 7881:7881/tcp
|
||||||
|
- 7882:7882/udp
|
||||||
|
volumes:
|
||||||
|
- ./livekit-server.yaml:/config.yaml
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=7880 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Deploy and Configure Keycloak for Meet
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2:. Update `env.d/` files
|
||||||
|
|
||||||
|
The following variables need to be updated with your own values, others can be left as is:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose keycloak instance on https
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You can skip this section if you already have your own setup.
|
||||||
|
|
||||||
|
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating an OIDC Client for Meet Application
|
||||||
|
|
||||||
|
### Step 1: Create a New Realm
|
||||||
|
|
||||||
|
1. Log in to the Keycloak administration console.
|
||||||
|
2. Navigate to the realm tab and click on the "Create realm" button.
|
||||||
|
3. Enter the name of the realm - `meet`.
|
||||||
|
4. Click "Create".
|
||||||
|
|
||||||
|
#### Step 2: Create a New Client
|
||||||
|
|
||||||
|
1. Navigate to the "Clients" tab.
|
||||||
|
2. Click on the "Create client" button.
|
||||||
|
3. Enter the client ID - e.g. `meet`.
|
||||||
|
4. Enable "Client authentication" option.
|
||||||
|
6. Set the "Valid redirect URIs" to the URL of your meet application suffixed with `/*` - e.g., "https://meet.example.com/*".
|
||||||
|
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||||
|
1. Click "Save".
|
||||||
|
|
||||||
|
#### Step 3: Get Client Credentials
|
||||||
|
|
||||||
|
1. Go to the "Credentials" tab.
|
||||||
|
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
volumes:
|
||||||
|
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:latest
|
||||||
|
command: ["start"]
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
- env.d/keycloak
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Nginx proxy with automatic SSL certificates
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
|
||||||
|
|
||||||
|
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
|
||||||
|
|
||||||
|
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir nginx-proxy && cd nginx-proxy
|
||||||
|
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
|
||||||
|
|
||||||
|
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
|
||||||
|
|
||||||
|
### Step 3: Create docker network
|
||||||
|
|
||||||
|
Containers need share the same network for auto-discovery.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create proxy-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginxproxy/nginx-proxy
|
||||||
|
container_name: nginx-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- html:/usr/share/nginx/html
|
||||||
|
- certs:/etc/nginx/certs:ro
|
||||||
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
acme-companion:
|
||||||
|
image: nginxproxy/acme-companion
|
||||||
|
container_name: nginx-proxy-acme
|
||||||
|
environment:
|
||||||
|
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||||
|
volumes_from:
|
||||||
|
- nginx-proxy
|
||||||
|
volumes:
|
||||||
|
- certs:/etc/nginx/certs:rw
|
||||||
|
- acme:/etc/acme.sh
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-tier:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
html:
|
||||||
|
certs:
|
||||||
|
acme:
|
||||||
@@ -68,13 +68,13 @@ backend:
|
|||||||
python manage.py createsuperuser --email admin@example.com --password admin
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
# 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:
|
extraVolumeMounts:
|
||||||
- name: certs
|
- 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
|
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:
|
extraVolumes:
|
||||||
- name: certs
|
- name: certs
|
||||||
configMap:
|
configMap:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
port: 7880
|
||||||
|
redis:
|
||||||
|
address: redis:6379
|
||||||
|
keys:
|
||||||
|
meet: <your livekit secret key>
|
||||||
|
# WebRTC configuration
|
||||||
|
rtc:
|
||||||
|
# # when set, LiveKit will attempt to use a UDP mux so all UDP traffic goes through
|
||||||
|
# # listed port(s). To maximize system performance, we recommend using a range of ports
|
||||||
|
# # greater or equal to the number of vCPUs on the machine.
|
||||||
|
# # port_range_start & end must not be set for this config to take effect
|
||||||
|
udp_port: 7882
|
||||||
|
# when set, LiveKit enable WebRTC ICE over TCP when UDP isn't available
|
||||||
|
# this port *cannot* be behind load balancer or TLS, and must be exposed on the node
|
||||||
|
# WebRTC transports are encrypted and do not require additional encryption
|
||||||
|
# only 80/443 on public IP are allowed if less than 1024
|
||||||
|
tcp_port: 7881
|
||||||
|
# use_external_ip should be set to true for most cloud environments where
|
||||||
|
# the host has a public IP address, but is not exposed to the process.
|
||||||
|
# LiveKit will attempt to use STUN to discover the true IP, and advertise
|
||||||
|
# that IP with its clients
|
||||||
|
use_external_ip: true
|
||||||
@@ -6,11 +6,12 @@ Here are a bunch of resources to help you install the project.
|
|||||||
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
|
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
We understand that not everyone has a Kubernetes cluster available.
|
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.
|
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
||||||
|
|
||||||
> [!WARNING]
|
## Scalingo
|
||||||
> Under construction: A PR is in progress to support deploying La Suite Meet via Docker Compose.
|
|
||||||
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Installation with docker compose
|
||||||
|
|
||||||
|
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
All services are required to run the minimalist instance of LaSuite Meet. Click the links for ready-to-use configuration examples:
|
||||||
|
|
||||||
|
| Service | Purpose | Example Config |
|
||||||
|
|-------------------|---------|----------------------------------------------------------|
|
||||||
|
| **PostgreSQL** | Main database | [compose.yaml](../examples/compose/compose.yaml) |
|
||||||
|
| **Redis** | Cache & sessions | [compose.yaml](../examples/compose/compose.yaml) |
|
||||||
|
| **Livekit** | Real-time communication | [compose.yaml](../examples/compose/compose.yaml) |
|
||||||
|
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
|
||||||
|
| **SMTP Service** | Email notifications | - |
|
||||||
|
|
||||||
|
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
|
||||||
|
|
||||||
|
|
||||||
|
## Software Requirements
|
||||||
|
|
||||||
|
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
|
||||||
|
|
||||||
|
Docker Compose is included with Docker Engine:
|
||||||
|
|
||||||
|
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
|
||||||
|
for [installing Docker Engine](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
|
For older versions of Docker Engine that do not include Docker Compose:
|
||||||
|
|
||||||
|
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
|
||||||
|
|
||||||
|
## Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p meet/env.d && cd meet
|
||||||
|
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/compose.yaml
|
||||||
|
curl -o .env https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/hosts
|
||||||
|
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/common
|
||||||
|
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/postgresql
|
||||||
|
curl -o livekit-server.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/livekit/server.yaml
|
||||||
|
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docker/files/production/default.conf.template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Configuration
|
||||||
|
|
||||||
|
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
||||||
|
|
||||||
|
In this example, we assume the following services:
|
||||||
|
|
||||||
|
- OIDC provider on https://id.yourdomain.tld
|
||||||
|
- Livekit server on https://livekit.yourdomain.tld
|
||||||
|
- Meet server on https://meet.yourdomain.tld
|
||||||
|
|
||||||
|
**Set your own values in `.env`**
|
||||||
|
|
||||||
|
### OIDC
|
||||||
|
|
||||||
|
Authentication in Meet is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
|
||||||
|
|
||||||
|
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
|
||||||
|
|
||||||
|
If using Keycloak as your Identity Provider, in `env.d/common` set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Meet. By default we have set `meet` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `.env`
|
||||||
|
|
||||||
|
For others OIDC providers, update the variables in `env.d/common`.
|
||||||
|
|
||||||
|
### Postgresql
|
||||||
|
|
||||||
|
Meet uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
|
||||||
|
|
||||||
|
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
Meet uses Redis for caching and inter-service communication. While an external Redis can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/common`.
|
||||||
|
|
||||||
|
### Livekit
|
||||||
|
|
||||||
|
[LiveKit](https://github.com/livekit/livekit) server is used as the WebRTC SFU (Selective Forwarding Unit) allowing multi-user conferencing. For more information, head to [livekit documentation](https://docs.livekit.io/home/self-hosting/).
|
||||||
|
|
||||||
|
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
||||||
|
|
||||||
|
We provide a minimal 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 performance, you can configure a range of UDP ports.
|
||||||
|
|
||||||
|
### Meet
|
||||||
|
|
||||||
|
The Meet backend is built on the Django Framework.
|
||||||
|
|
||||||
|
Generate a [secure key](https://docs.djangoproject.com/en/5.2/ref/settings/#secret-key.) for `DJANGO_SECRET_KEY` in `env.d/common`.
|
||||||
|
|
||||||
|
### Mail
|
||||||
|
|
||||||
|
The following environment variables are required in `env.d/common` for the mail service to send invitations :
|
||||||
|
|
||||||
|
```env
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Configure your firewall
|
||||||
|
|
||||||
|
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
||||||
|
|
||||||
|
The following ports will need to be opened:
|
||||||
|
- 80/tcp - for TLS issuance
|
||||||
|
- 443/tcp - for listening on HTTPS and TURN/TLS packets
|
||||||
|
- 7881/tcp - WebRTC ICE over TCP
|
||||||
|
- 7882/udp - for WebRTC multiplexing over UDP
|
||||||
|
|
||||||
|
If you are using ufw, enter the following:
|
||||||
|
```
|
||||||
|
ufw allow 80/tcp
|
||||||
|
ufw allow 443/tcp
|
||||||
|
ufw allow 443/udp
|
||||||
|
ufw allow 7881/tcp
|
||||||
|
ufw allow 7882/udp
|
||||||
|
ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Reverse proxy and SSL/TLS
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> In a production environment, configure SSL/TLS termination to run your instance on https.
|
||||||
|
|
||||||
|
If you have your own certificates and proxy setup, you can skip this part.
|
||||||
|
|
||||||
|
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
|
||||||
|
|
||||||
|
You will need to uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
...
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=7880 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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 !
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
> [!NOTE]
|
||||||
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
|
||||||
|
|
||||||
|
## Step 6: Run the database migration and create Django admin user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<admin email>` with the email of your admin user and generate a secure password.
|
||||||
|
|
||||||
|
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
|
||||||
|
|
||||||
|
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||||
|
|
||||||
|
## How to upgrade your Meet application
|
||||||
|
|
||||||
|
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
|
||||||
|
|
||||||
|
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
|
||||||
|
|
||||||
|
### Step 1: Edit the images tag with the desired version
|
||||||
|
|
||||||
|
### Step 2: Pull the images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Restart your containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run the database migration
|
||||||
|
Your database schema may need to be updated, run:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
|
|||||||
$ kubectl create namespace meet
|
$ kubectl create namespace meet
|
||||||
```
|
```
|
||||||
|
|
||||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ 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
|
$ 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
|
$ #wait until
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
|
|||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can find these values in **examples/keycloak.values.yaml**
|
You can find these values in **examples/helm/keycloak.values.yaml**
|
||||||
|
|
||||||
### Find livekit server connexion values
|
### Find livekit server connexion values
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
|
|||||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 26m
|
keycloak-0 1/1 Running 0 26m
|
||||||
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
|
|||||||
```
|
```
|
||||||
$ helm repo add livekit https://helm.livekit.io
|
$ helm repo add livekit https://helm.livekit.io
|
||||||
$ helm repo update
|
$ helm repo update
|
||||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 30m
|
keycloak-0 1/1 Running 0 30m
|
||||||
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
|||||||
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 45m
|
keycloak-0 1/1 Running 0 45m
|
||||||
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
|
|||||||
```
|
```
|
||||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||||
$ helm repo update
|
$ helm repo update
|
||||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test your deployment
|
## 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_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_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_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_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| 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/)
|
||||||
+3
-4
@@ -7,7 +7,7 @@ info:
|
|||||||
|
|
||||||
#### Authentication Flow
|
#### Authentication Flow
|
||||||
|
|
||||||
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/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.
|
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.
|
3. Tokens are scoped and allow applications to act on behalf of specific users.
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ info:
|
|||||||
|
|
||||||
#### Upcoming Features
|
#### 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.
|
* **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.
|
* **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
|
description: Room management operations
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/applications/token:
|
/application/token/:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Authentication
|
- Authentication
|
||||||
@@ -283,7 +282,7 @@ components:
|
|||||||
scheme: bearer
|
scheme: bearer
|
||||||
bearerFormat: JWT
|
bearerFormat: JWT
|
||||||
description: |
|
description: |
|
||||||
JWT token obtained from the `/applications/token` endpoint.
|
JWT token obtained from the `/application/token/` endpoint.
|
||||||
Include in requests as: `Authorization: Bearer <token>`
|
Include in requests as: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Meet External API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
External API for room management with resource server authentication.
|
||||||
|
[[description by Oauth 2.0]](https://www.oauth.com/oauth2-servers/the-resource-server/)
|
||||||
|
|
||||||
|
#### Authentication Flow
|
||||||
|
|
||||||
|
1. Authenticate with the authorization server using your credentials
|
||||||
|
2. During authentication, request the scopes you need: `lasuite_visio` (mandatory) plus action-specific scopes
|
||||||
|
3. Receive an access token and a refresh token that includes the requested scopes
|
||||||
|
4. Use the access token in the `Authorization: Bearer <token>` header for all API requests
|
||||||
|
5. When the access token expires, use the refresh token to obtain a new access token without re-authenticating
|
||||||
|
|
||||||
|
#### Scopes
|
||||||
|
|
||||||
|
* `lasuite_visio` - **Mandatory** Base scope required for any API access
|
||||||
|
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||||
|
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||||
|
* `lasuite_visio:rooms:create` – Create new rooms.
|
||||||
|
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||||
|
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
|
#### Upcoming Features
|
||||||
|
|
||||||
|
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||||
|
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||||
|
|
||||||
|
contact:
|
||||||
|
name: API Support
|
||||||
|
email: antoine.lebaud@mail.numerique.gouv.fr
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
|
||||||
|
description: Sandbox server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Rooms
|
||||||
|
description: Room management operations
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/rooms:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Rooms
|
||||||
|
summary: List rooms
|
||||||
|
description: |
|
||||||
|
Returns a list of rooms accessible to the authenticated user.
|
||||||
|
Only rooms where the delegated user has access will be returned.
|
||||||
|
operationId: listRooms
|
||||||
|
security:
|
||||||
|
- BearerAuth: [rooms:list]
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: Page number for pagination
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
- name: page_size
|
||||||
|
in: query
|
||||||
|
description: Number of items per page
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of accessible rooms
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
description: Total number of rooms
|
||||||
|
next:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: URL to next page
|
||||||
|
previous:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: URL to previous page
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Room'
|
||||||
|
examples:
|
||||||
|
roomList:
|
||||||
|
summary: Paginated room list
|
||||||
|
value:
|
||||||
|
count: 2
|
||||||
|
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
|
||||||
|
previous: null
|
||||||
|
results:
|
||||||
|
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||||
|
slug: "aae-erez-aaz"
|
||||||
|
access_level: "trusted"
|
||||||
|
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
|
||||||
|
telephony:
|
||||||
|
enabled: true
|
||||||
|
pin_code: "123456"
|
||||||
|
phone_number: "+1-555-0100"
|
||||||
|
default_country: "US"
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/UnauthorizedError'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Rooms
|
||||||
|
summary: Create a room
|
||||||
|
description: |
|
||||||
|
Creates a new room with secure defaults for external API usage.
|
||||||
|
|
||||||
|
**Restrictions:**
|
||||||
|
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||||
|
- Room access_level can be updated from the webapp interface.
|
||||||
|
|
||||||
|
**Defaults:**
|
||||||
|
- Delegated user is set as owner
|
||||||
|
- Room slug auto-generated for uniqueness
|
||||||
|
- Telephony PIN auto-generated when enabled
|
||||||
|
- Creation tracked with application client_id for auditing
|
||||||
|
operationId: createRoom
|
||||||
|
security:
|
||||||
|
- BearerAuth: [rooms:create]
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoomCreate'
|
||||||
|
examples:
|
||||||
|
emptyBody:
|
||||||
|
summary: No parameters (default)
|
||||||
|
value: {}
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Room created successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Room'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/UnauthorizedError'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
|
/rooms/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Rooms
|
||||||
|
summary: Retrieve a room
|
||||||
|
description: Get detailed information about a specific room by its ID
|
||||||
|
operationId: retrieveRoom
|
||||||
|
security:
|
||||||
|
- BearerAuth: [rooms:retrieve]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Room UUID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Room details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Room'
|
||||||
|
examples:
|
||||||
|
room:
|
||||||
|
summary: Room details
|
||||||
|
value:
|
||||||
|
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||||
|
slug: "aae-erez-aaz"
|
||||||
|
access_level: "trusted"
|
||||||
|
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
|
||||||
|
telephony:
|
||||||
|
enabled: true
|
||||||
|
pin_code: "123456"
|
||||||
|
phone_number: "+1-555-0100"
|
||||||
|
default_country: "US"
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/UnauthorizedError'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/RoomNotFoundError'
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: |
|
||||||
|
JWT token obtained from the `/application/token` endpoint.
|
||||||
|
Include in requests as: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
TokenRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- client_id
|
||||||
|
- client_secret
|
||||||
|
- grant_type
|
||||||
|
- scope
|
||||||
|
properties:
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
description: Application client identifier
|
||||||
|
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
client_secret:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
writeOnly: true
|
||||||
|
description: Application secret key
|
||||||
|
example: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||||
|
grant_type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- client_credentials
|
||||||
|
description: OAuth2 grant type (must be 'client_credentials')
|
||||||
|
example: "client_credentials"
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: |
|
||||||
|
Email address of the user to delegate.
|
||||||
|
The application will act on behalf of this user.
|
||||||
|
Note: This parameter is named 'scope' to align with OAuth2 conventions,
|
||||||
|
but accepts an email address to identify the user. This design allows
|
||||||
|
for future extensibility.
|
||||||
|
example: "user@example.com"
|
||||||
|
|
||||||
|
TokenResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
access_token:
|
||||||
|
type: string
|
||||||
|
description: JWT access token
|
||||||
|
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
|
||||||
|
token_type:
|
||||||
|
type: string
|
||||||
|
description: Token type (always 'Bearer')
|
||||||
|
example: "Bearer"
|
||||||
|
expires_in:
|
||||||
|
type: integer
|
||||||
|
description: Token lifetime in seconds
|
||||||
|
example: 3600
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
description: Space-separated list of granted permission scopes
|
||||||
|
example: "rooms:list rooms:retrieve rooms:create"
|
||||||
|
|
||||||
|
RoomCreate:
|
||||||
|
type: object
|
||||||
|
description: Empty object - all room properties are auto-generated
|
||||||
|
properties: {}
|
||||||
|
|
||||||
|
Room:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
readOnly: true
|
||||||
|
description: Unique room identifier
|
||||||
|
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
description: URL-friendly room identifier (auto-generated)
|
||||||
|
example: "aze-eere-zer"
|
||||||
|
access_level:
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
description: Room access level (always 'trusted' for API-created rooms)
|
||||||
|
example: "trusted"
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
readOnly: true
|
||||||
|
description: Full URL to access the room
|
||||||
|
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
|
||||||
|
telephony:
|
||||||
|
type: object
|
||||||
|
readOnly: true
|
||||||
|
description: Telephony dial-in information (if enabled)
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether telephony is available
|
||||||
|
example: true
|
||||||
|
pin_code:
|
||||||
|
type: string
|
||||||
|
description: PIN code for dial-in access
|
||||||
|
example: "123456"
|
||||||
|
phone_number:
|
||||||
|
type: string
|
||||||
|
description: Phone number to dial
|
||||||
|
example: "+1-555-0100"
|
||||||
|
default_country:
|
||||||
|
type: string
|
||||||
|
description: Default country code
|
||||||
|
example: "US"
|
||||||
|
|
||||||
|
OAuthError:
|
||||||
|
type: object
|
||||||
|
description: OAuth2-compliant error response
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Human-readable error description
|
||||||
|
example: "Invalid credentials"
|
||||||
|
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
description: Error message
|
||||||
|
example: "Invalid token."
|
||||||
|
|
||||||
|
ValidationError:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
field_name:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: List of validation errors for this field
|
||||||
|
example: ["This field is required."]
|
||||||
|
|
||||||
|
responses:
|
||||||
|
UnauthorizedError:
|
||||||
|
description: The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
examples:
|
||||||
|
invalidToken:
|
||||||
|
summary: Invalid token
|
||||||
|
value:
|
||||||
|
error: "Invalid token."
|
||||||
|
|
||||||
|
ForbiddenError:
|
||||||
|
description: Insufficient scope for this operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
examples:
|
||||||
|
insufficientScope:
|
||||||
|
summary: Missing required scope
|
||||||
|
value:
|
||||||
|
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
|
||||||
|
|
||||||
|
RoomNotFoundError:
|
||||||
|
description: Room not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
examples:
|
||||||
|
roomNotFound:
|
||||||
|
summary: Room does not exist
|
||||||
|
value:
|
||||||
|
detail: "Not found."
|
||||||
|
|
||||||
|
BadRequestError:
|
||||||
|
description: Invalid request data
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
examples:
|
||||||
|
validationError:
|
||||||
|
summary: Field validation failed
|
||||||
|
value:
|
||||||
|
scope: ["Invalid email address."]
|
||||||
@@ -23,15 +23,20 @@ MEET_BASE_URL="http://localhost:8072"
|
|||||||
|
|
||||||
# Media
|
# Media
|
||||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
|
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||||
AWS_S3_ACCESS_KEY_ID=meet
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
|
MEDIA_BASE_URL=http://localhost:8083
|
||||||
|
FILE_UPLOAD_ENABLED=True
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
||||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
||||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
||||||
|
OIDC_OP_URL=http://localhost:8083/realms/meet
|
||||||
|
|
||||||
OIDC_RP_CLIENT_ID=meet
|
OIDC_RP_CLIENT_ID=meet
|
||||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
@@ -45,6 +50,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
|||||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||||
|
|
||||||
|
OIDC_RS_CLIENT_ID=meet
|
||||||
|
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
|
||||||
# Livekit Token settings
|
# Livekit Token settings
|
||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
@@ -58,7 +66,7 @@ RECORDING_STORAGE_EVENT_ENABLE=True
|
|||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
APP_NAME="meet-app-summary-dev"
|
APP_NAME="meet-app-summary-dev"
|
||||||
APP_API_TOKEN="password"
|
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_ENDPOINT_URL="minio:9000"
|
||||||
AWS_S3_SECURE_ACCESS=false
|
AWS_S3_SECURE_ACCESS=false
|
||||||
|
|
||||||
@@ -11,10 +11,11 @@ AWS_S3_SECRET_ACCESS_KEY="password"
|
|||||||
WHISPERX_BASE_URL="https://configure-your-url.com"
|
WHISPERX_BASE_URL="https://configure-your-url.com"
|
||||||
WHISPERX_ASR_MODEL="large-v2"
|
WHISPERX_ASR_MODEL="large-v2"
|
||||||
WHISPERX_API_KEY="your-secret-key"
|
WHISPERX_API_KEY="your-secret-key"
|
||||||
|
WHISPERX_DEFAULT_LANGUAGE="fr"
|
||||||
|
|
||||||
LLM_BASE_URL="https://configure-your-url.com"
|
LLM_BASE_URL="https://configure-your-url.com"
|
||||||
LLM_API_KEY="dev-apikey"
|
LLM_API_KEY="dev-apikey"
|
||||||
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
|
LLM_MODEL="albert-large"
|
||||||
|
|
||||||
WEBHOOK_API_TOKEN="secret"
|
WEBHOOK_API_TOKEN="secret"
|
||||||
WEBHOOK_URL="https://configure-your-url.com"
|
WEBHOOK_URL="https://configure-your-url.com"
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Django
|
||||||
|
DJANGO_ALLOWED_HOSTS=${MEET_HOST}
|
||||||
|
DJANGO_SECRET_KEY=<generate a secret key>
|
||||||
|
DJANGO_SETTINGS_MODULE=meet.settings
|
||||||
|
DJANGO_CONFIGURATION=Production
|
||||||
|
|
||||||
|
# Python
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Meet settings
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
|
||||||
|
|
||||||
|
# Backend url
|
||||||
|
MEET_BASE_URL="https://${MEET_HOST}"
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
|
||||||
|
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
|
||||||
|
|
||||||
|
OIDC_RP_CLIENT_ID=<client_id>
|
||||||
|
OIDC_RP_CLIENT_SECRET=<client secret>
|
||||||
|
OIDC_RP_SIGN_ALGO=RS256
|
||||||
|
OIDC_RP_SCOPES="openid email"
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL=https://${MEET_HOST}
|
||||||
|
LOGIN_REDIRECT_URL_FAILURE=https://${MEET_HOST}
|
||||||
|
LOGOUT_REDIRECT_URL=https://${MEET_HOST}
|
||||||
|
|
||||||
|
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${MEET_HOST}"]
|
||||||
|
|
||||||
|
# Livekit Token settings
|
||||||
|
LIVEKIT_API_SECRET=<generate a secret key>
|
||||||
|
LIVEKIT_API_KEY=meet
|
||||||
|
LIVEKIT_API_URL=https://${LIVEKIT_HOST}
|
||||||
|
ALLOW_UNREGISTERED_ROOMS=False
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
MEET_HOST=meet.domain.tld
|
||||||
|
KEYCLOAK_HOST=id.domain.tld
|
||||||
|
LIVEKIT_HOST=livekit.domain.tld
|
||||||
|
BACKEND_INTERNAL_HOST=backend
|
||||||
|
FRONTEND_INTERNAL_HOST=frontend
|
||||||
|
LIVEKIT_INTERNAL_HOST=livekit
|
||||||
|
REALM_NAME=meet
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=keycloak
|
||||||
|
POSTGRES_USER=keycloak
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
# Keycloak postgresql configuration
|
||||||
|
KC_DB=postgres
|
||||||
|
KC_DB_SCHEMA=public
|
||||||
|
KC_DB_URL_HOST=postgresql
|
||||||
|
KC_DB_NAME=${POSTGRES_DB}
|
||||||
|
KC_DB_USER=${POSTGRES_USER}
|
||||||
|
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Keycloak admin user
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
|
||||||
|
# Keycloak configuration
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
|
||||||
|
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# App database configuration
|
||||||
|
DB_HOST=postgresql
|
||||||
|
DB_NAME=meet
|
||||||
|
DB_USER=meet
|
||||||
|
DB_PASSWORD=<generate a secure password>
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=meet
|
||||||
|
POSTGRES_USER=meet
|
||||||
|
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
Gitlint extra rule to validate that the message title is of the form
|
Gitlint extra rule to validate that the message title is of the form
|
||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -3,12 +3,39 @@
|
|||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog"],
|
||||||
"packageRules": [
|
"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,
|
"enabled": false,
|
||||||
"groupName": "ignored python dependencies",
|
"groupName": "ignored python dependencies",
|
||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
"matchPackageNames": ["redis"]
|
"matchPackageNames": ["redis"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed pylint versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["pylint"],
|
||||||
|
"allowedVersions": "<4.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed django versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["django"],
|
||||||
|
"allowedVersions": "<6.0.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,13 @@
|
|||||||
FROM python:3.13-slim AS base
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
|
# Install system dependencies required by LiveKit
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgobject-2.0-0 \
|
||||||
|
"openssl=3.5.4-1~deb13u2" \
|
||||||
|
"libssl3t64=3.5.4-1~deb13u2" \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
@@ -13,6 +21,9 @@ FROM base AS production
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Remove pip to reduce attack surface in production
|
||||||
|
RUN pip uninstall -y pip
|
||||||
|
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
USER ${DOCKER_USER}
|
USER ${DOCKER_USER}
|
||||||
|
|
||||||
@@ -21,4 +32,4 @@ COPY --from=builder /install /usr/local
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
"""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,
|
|
||||||
AgentSession,
|
|
||||||
AutoSubscribe,
|
|
||||||
JobContext,
|
|
||||||
JobProcess,
|
|
||||||
JobRequest,
|
|
||||||
RoomInputOptions,
|
|
||||||
RoomIO,
|
|
||||||
RoomOutputOptions,
|
|
||||||
WorkerOptions,
|
|
||||||
WorkerPermissions,
|
|
||||||
cli,
|
|
||||||
utils,
|
|
||||||
)
|
|
||||||
from livekit.plugins import silero
|
|
||||||
from minio import Minio
|
|
||||||
from minio.error import S3Error
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
logger = logging.getLogger("metadata-extractor")
|
|
||||||
|
|
||||||
AGENT_NAME = os.getenv("ROOM_METADATA_EXTRACTOR_AGENT_NAME", "metadata-extractor")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MetadataEvent:
|
|
||||||
"""Wip."""
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Wip."""
|
|
||||||
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 MetadataAgent:
|
|
||||||
"""Monitor and manage real-time metadata extraction from meeting rooms.
|
|
||||||
|
|
||||||
Oversees VAD (Voice Activity Detection) and participant metadata streams
|
|
||||||
to track and analyze real-time events, coordinating data collection across
|
|
||||||
participants for insights like speaking activity and engagement.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# todo - raise error if none
|
|
||||||
self.bucket_name = os.getenv("AWS_STORAGE_BUCKET_NAME")
|
|
||||||
|
|
||||||
self.ctx = ctx
|
|
||||||
self._sessions: dict[str, AgentSession] = {}
|
|
||||||
self._tasks: set[asyncio.Task] = set()
|
|
||||||
|
|
||||||
self.output_filename = (
|
|
||||||
f"{os.getenv('AWS_S3_OUTPUT_FOLDER', 'metadata')}/{recording_id}-metadata.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Storage for events
|
|
||||||
self.events = []
|
|
||||||
self.participants = {}
|
|
||||||
|
|
||||||
logger.info("MetadataAgent initialized")
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start listening for participant connection events."""
|
|
||||||
self.ctx.room.on("participant_connected", self.on_participant_connected)
|
|
||||||
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
|
|
||||||
):
|
|
||||||
"""Wip."""
|
|
||||||
full_text = await reader.read_all()
|
|
||||||
logger.info(
|
|
||||||
"Received chat message from %s: '%s'", participant_identity, full_text
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Wip."""
|
|
||||||
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):
|
|
||||||
"""Wip."""
|
|
||||||
logger.info("Persisting processed metadata output to disk…")
|
|
||||||
|
|
||||||
participants = []
|
|
||||||
for k, v in self.participants.items():
|
|
||||||
participants.append({"participantId": k, "name": v})
|
|
||||||
|
|
||||||
sorted_event = sorted(self.events, key=lambda e: e.timestamp)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"events": [event.serialize() for event in sorted_event],
|
|
||||||
"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_connected", self.on_participant_connected)
|
|
||||||
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()
|
|
||||||
|
|
||||||
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
|
||||||
"""Handle new participant connection by starting VAD monitoring."""
|
|
||||||
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)
|
|
||||||
task = asyncio.create_task(self._start_session(participant))
|
|
||||||
self._tasks.add(task)
|
|
||||||
|
|
||||||
def on_task_done(task: asyncio.Task):
|
|
||||||
try:
|
|
||||||
self._sessions[participant.identity] = task.result()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to start session for %s", participant.identity)
|
|
||||||
finally:
|
|
||||||
self._tasks.discard(task)
|
|
||||||
|
|
||||||
task.add_done_callback(on_task_done)
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Wip."""
|
|
||||||
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 entrypoint(ctx: JobContext):
|
|
||||||
"""Initialize and run the multi-user VAD monitor."""
|
|
||||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
|
||||||
recording_id = ctx.job.metadata
|
|
||||||
vad_monitor = MetadataAgent(ctx, recording_id)
|
|
||||||
vad_monitor.start()
|
|
||||||
|
|
||||||
# Connect to room and subscribe to audio only
|
|
||||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
|
||||||
|
|
||||||
existing_participants = list(ctx.room.remote_participants.values())
|
|
||||||
for participant in existing_participants:
|
|
||||||
vad_monitor.on_participant_connected(participant)
|
|
||||||
|
|
||||||
async def cleanup():
|
|
||||||
logger.info("Shutting down VAD monitor...")
|
|
||||||
await vad_monitor.aclose()
|
|
||||||
|
|
||||||
ctx.add_shutdown_callback(cleanup)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
|
||||||
"""Preload voice activity detection model."""
|
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
cli.run_app(
|
|
||||||
WorkerOptions(
|
|
||||||
entrypoint_fnc=entrypoint,
|
|
||||||
prewarm_fnc=prewarm,
|
|
||||||
request_fnc=handle_job_request,
|
|
||||||
agent_name=AGENT_NAME,
|
|
||||||
permissions=WorkerPermissions(
|
|
||||||
can_publish=False,
|
|
||||||
can_publish_data=False,
|
|
||||||
can_subscribe=True,
|
|
||||||
hidden=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from lasuite.plugins import kyutai
|
||||||
from livekit import api, rtc
|
from livekit import api, rtc
|
||||||
from livekit.agents import (
|
from livekit.agents import (
|
||||||
Agent,
|
Agent,
|
||||||
@@ -13,14 +14,15 @@ from livekit.agents import (
|
|||||||
JobContext,
|
JobContext,
|
||||||
JobProcess,
|
JobProcess,
|
||||||
JobRequest,
|
JobRequest,
|
||||||
RoomInputOptions,
|
|
||||||
RoomIO,
|
RoomIO,
|
||||||
RoomOutputOptions,
|
|
||||||
WorkerOptions,
|
WorkerOptions,
|
||||||
WorkerPermissions,
|
WorkerPermissions,
|
||||||
cli,
|
cli,
|
||||||
utils,
|
utils,
|
||||||
)
|
)
|
||||||
|
from livekit.agents import (
|
||||||
|
room_io as lk_room_io,
|
||||||
|
)
|
||||||
from livekit.plugins import deepgram, silero
|
from livekit.plugins import deepgram, silero
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -28,6 +30,26 @@ load_dotenv()
|
|||||||
logger = logging.getLogger("transcriber")
|
logger = logging.getLogger("transcriber")
|
||||||
|
|
||||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||||
|
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||||
|
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def create_stt_provider():
|
||||||
|
"""Create STT provider based on environment configuration."""
|
||||||
|
if STT_PROVIDER == "deepgram":
|
||||||
|
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||||
|
# detect_language is NOT supported for real-time streaming
|
||||||
|
# Use language="multi" instead for automatic multilingual support
|
||||||
|
_stt_instance = deepgram.STT(
|
||||||
|
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
|
||||||
|
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
|
||||||
|
)
|
||||||
|
elif STT_PROVIDER == "kyutai":
|
||||||
|
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||||
|
|
||||||
|
return _stt_instance
|
||||||
|
|
||||||
|
|
||||||
class Transcriber(Agent):
|
class Transcriber(Agent):
|
||||||
@@ -35,9 +57,11 @@ class Transcriber(Agent):
|
|||||||
|
|
||||||
def __init__(self, *, participant_identity: str):
|
def __init__(self, *, participant_identity: str):
|
||||||
"""Init transcription agent."""
|
"""Init transcription agent."""
|
||||||
|
stt = create_stt_provider()
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
instructions="not-needed",
|
instructions="not-needed",
|
||||||
stt=deepgram.STT(),
|
stt=stt,
|
||||||
)
|
)
|
||||||
self.participant_identity = participant_identity
|
self.participant_identity = participant_identity
|
||||||
|
|
||||||
@@ -99,19 +123,14 @@ class MultiUserTranscriber:
|
|||||||
if participant.identity in self._sessions:
|
if participant.identity in self._sessions:
|
||||||
return self._sessions[participant.identity]
|
return self._sessions[participant.identity]
|
||||||
|
|
||||||
session = AgentSession(
|
vad = self.ctx.proc.userdata.get("vad", None)
|
||||||
vad=self.ctx.proc.userdata["vad"],
|
session = AgentSession(vad=vad)
|
||||||
)
|
|
||||||
room_io = RoomIO(
|
room_io = RoomIO(
|
||||||
agent_session=session,
|
agent_session=session,
|
||||||
room=self.ctx.room,
|
room=self.ctx.room,
|
||||||
participant=participant,
|
participant=participant,
|
||||||
input_options=RoomInputOptions(
|
options=lk_room_io.RoomOptions(
|
||||||
text_enabled=False,
|
text_input=False, audio_output=False, text_output=True
|
||||||
),
|
|
||||||
output_options=RoomOutputOptions(
|
|
||||||
transcription_enabled=True,
|
|
||||||
audio_enabled=False,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await room_io.start()
|
await room_io.start()
|
||||||
@@ -174,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
if ENABLE_SILERO_VAD:
|
||||||
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "0.1.39"
|
version = "1.10.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.2.6",
|
"livekit-agents==1.3.10",
|
||||||
"livekit-plugins-deepgram==1.2.6",
|
"livekit-plugins-deepgram==1.3.10",
|
||||||
"livekit-plugins-silero==1.2.6",
|
"livekit-plugins-silero==1.3.10",
|
||||||
"python-dotenv==1.1.1",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"minio==7.2.15"
|
"python-dotenv==1.2.1",
|
||||||
|
"protobuf==6.33.5"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.12.0",
|
"ruff==0.14.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
+134
-6
@@ -1,10 +1,12 @@
|
|||||||
"""Admin classes and registrations for core app."""
|
"""Admin classes and registrations for core app."""
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth import admin as auth_admin
|
from django.contrib.auth import admin as auth_admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.recording.event import notification
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
@@ -109,16 +111,122 @@ class RoomAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
inlines = (ResourceAccessInline,)
|
inlines = (ResourceAccessInline,)
|
||||||
search_fields = ["name", "slug", "=id"]
|
search_fields = ["name", "slug", "=id"]
|
||||||
list_display = ["name", "slug", "access_level", "created_at"]
|
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
|
||||||
list_filter = ["access_level", "created_at"]
|
list_filter = ["access_level", "created_at"]
|
||||||
readonly_fields = ["id", "created_at", "updated_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."""
|
||||||
|
|
||||||
|
owners = [
|
||||||
|
access
|
||||||
|
for access in obj.accesses.all()
|
||||||
|
if access.role == models.RoleChoices.OWNER
|
||||||
|
]
|
||||||
|
|
||||||
|
if not owners:
|
||||||
|
return _("No owner")
|
||||||
|
|
||||||
|
if len(owners) > 1:
|
||||||
|
return _("Multiple owners")
|
||||||
|
|
||||||
|
return str(owners[0].user)
|
||||||
|
|
||||||
|
|
||||||
class RecordingAccessInline(admin.TabularInline):
|
class RecordingAccessInline(admin.TabularInline):
|
||||||
"""Inline admin class for recording accesses."""
|
"""Inline admin class for recording accesses."""
|
||||||
|
|
||||||
model = models.RecordingAccess
|
model = models.RecordingAccess
|
||||||
extra = 0
|
extra = 0
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description=_("Resend notification to external service"))
|
||||||
|
def resend_notification(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||||
|
"""Resend notification to external service for selected recordings."""
|
||||||
|
|
||||||
|
notification_service = notification.NotificationService()
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for recording in queryset:
|
||||||
|
if recording.is_expired:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = notification_service.notify_external_services(recording)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
processed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
modeladmin.message_user(
|
||||||
|
request,
|
||||||
|
_("Failed to notify for recording %(id)s") % {"id": recording.id},
|
||||||
|
level=messages.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
|
||||||
|
failed += 1
|
||||||
|
modeladmin.message_user(
|
||||||
|
request,
|
||||||
|
_("Failed to notify for recording %(id)s: %(error)s")
|
||||||
|
% {"id": recording.id, "error": str(e)},
|
||||||
|
level=messages.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
if processed > 0:
|
||||||
|
modeladmin.message_user(
|
||||||
|
request,
|
||||||
|
_("Successfully sent notifications for %(count)s recording(s).")
|
||||||
|
% {"count": processed},
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if skipped > 0:
|
||||||
|
modeladmin.message_user(
|
||||||
|
request,
|
||||||
|
_("Skipped %(count)s expired recording(s).") % {"count": skipped},
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
|
||||||
|
def mark_as_failed_to_stop(modeladmin, request, queryset):
|
||||||
|
"""Force selected recordings status to failed_to_stop."""
|
||||||
|
|
||||||
|
eligible_statuses = [
|
||||||
|
models.RecordingStatusChoices.ACTIVE,
|
||||||
|
models.RecordingStatusChoices.INITIATED,
|
||||||
|
models.RecordingStatusChoices.STOPPED,
|
||||||
|
]
|
||||||
|
|
||||||
|
eligible = queryset.filter(status__in=eligible_statuses)
|
||||||
|
skipped = queryset.exclude(status__in=eligible_statuses).count()
|
||||||
|
|
||||||
|
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
|
||||||
|
|
||||||
|
if updated > 0:
|
||||||
|
modeladmin.message_user(
|
||||||
|
request,
|
||||||
|
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
|
||||||
|
% {"count": updated},
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if skipped > 0:
|
||||||
|
modeladmin.message_user(
|
||||||
|
request,
|
||||||
|
_("Skipped %(count)s recording(s) with an ineligible status.")
|
||||||
|
% {"count": skipped},
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Recording)
|
@admin.register(models.Recording)
|
||||||
@@ -127,9 +235,28 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||||
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
|
list_display = (
|
||||||
list_filter = ["status", "room", "created_at"]
|
"id",
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
"status",
|
||||||
|
"mode",
|
||||||
|
"room",
|
||||||
|
"get_owner",
|
||||||
|
"created_at",
|
||||||
|
"worker_id",
|
||||||
|
)
|
||||||
|
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):
|
def get_queryset(self, request):
|
||||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||||
@@ -181,7 +308,7 @@ class ApplicationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
form = ApplicationAdminForm
|
form = ApplicationAdminForm
|
||||||
|
|
||||||
list_display = ("id", "name", "client_id", "get_scopes_display")
|
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
"id",
|
"id",
|
||||||
@@ -190,6 +317,7 @@ class ApplicationAdmin(admin.ModelAdmin):
|
|||||||
"scopes",
|
"scopes",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
|
"is_active",
|
||||||
]
|
]
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
readonly_fields = ["id", "created_at", "updated_at"]
|
||||||
inlines = [ApplicationDomainInline]
|
inlines = [ApplicationDomainInline]
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ def get_frontend_configuration(request):
|
|||||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||||
},
|
},
|
||||||
|
"background_image": {
|
||||||
|
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||||
|
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||||
|
"max_count_by_user"
|
||||||
|
],
|
||||||
|
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||||
|
"max_size"
|
||||||
|
],
|
||||||
|
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||||
|
"allowed_extensions"
|
||||||
|
],
|
||||||
|
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
||||||
|
"allowed_mimetypes"
|
||||||
|
],
|
||||||
|
},
|
||||||
"telephony": {
|
"telephony": {
|
||||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class FeatureFlag:
|
|||||||
"recording": "RECORDING_ENABLE",
|
"recording": "RECORDING_ENABLE",
|
||||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||||
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -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."""
|
"""Permission handlers for the Meet core app."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
@@ -106,3 +109,30 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
|
|||||||
if not request.auth or not hasattr(request.auth, "video"):
|
if not request.auth or not hasattr(request.auth, "video"):
|
||||||
return False
|
return False
|
||||||
return request.auth.video.room == str(obj.id)
|
return request.auth.video.room == str(obj.id)
|
||||||
|
|
||||||
|
|
||||||
|
class FilePermission(IsAuthenticated):
|
||||||
|
"""
|
||||||
|
Permissions applying to the file API endpoint.
|
||||||
|
Handling soft deletions specificities
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Allow access only to authenticated users."""
|
||||||
|
if not settings.FILE_UPLOAD_ENABLED:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
return super().has_permission(request, view)
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""
|
||||||
|
Return a 404 on deleted files or if the user is not the owner
|
||||||
|
"""
|
||||||
|
|
||||||
|
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
if obj.creator != request.user:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
return obj.get_abilities(request.user).get(view.action, False)
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
"""Client serializers for the Meet core app."""
|
"""Client serializers for the Meet core app."""
|
||||||
|
|
||||||
# pylint: disable=abstract-method,no-name-in-module
|
# pylint: disable=abstract-method,no-name-in-module
|
||||||
|
import logging
|
||||||
|
from os.path import splitext
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method,no-name-in-module
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from livekit.api import ParticipantPermission
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
@@ -23,6 +34,15 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize users with limited fields."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["id", "full_name", "short_name"]
|
||||||
|
read_only_fields = ["id", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessSerializerMixin:
|
class ResourceAccessSerializerMixin:
|
||||||
"""
|
"""
|
||||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||||
@@ -159,6 +179,8 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
is_admin_or_owner=is_admin_or_owner,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
del output["pin_code"]
|
||||||
|
|
||||||
output["is_administrable"] = is_admin_or_owner
|
output["is_administrable"] = is_admin_or_owner
|
||||||
|
|
||||||
@@ -179,6 +201,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"status",
|
"status",
|
||||||
"mode",
|
"mode",
|
||||||
|
"options",
|
||||||
"key",
|
"key",
|
||||||
"is_expired",
|
"is_expired",
|
||||||
"expired_at",
|
"expired_at",
|
||||||
@@ -198,6 +221,27 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingOptions(BaseModel):
|
||||||
|
"""Configuration options for recording.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
language: ISO 639-1 language code compatible with whisperX.
|
||||||
|
When `None`, the transcription engine will attempt to
|
||||||
|
auto-detect the spoken language.
|
||||||
|
transcribe: Whether to transcribe the recorded audio.
|
||||||
|
When `None`, falls back to the application default.
|
||||||
|
original_mode: The original recording mode before any override.
|
||||||
|
Must be one of the valid RecordingModeChoices values when provided.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
language: str | None = None
|
||||||
|
transcribe: bool | None = None
|
||||||
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate start recording requests."""
|
"""Validate start recording requests."""
|
||||||
|
|
||||||
@@ -210,6 +254,12 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
"screen_recording or transcript.",
|
"screen_recording or transcript.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
options = SchemaField(
|
||||||
|
schema=RecordingOptions | None,
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
help_text="Recording options",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||||
@@ -253,6 +303,28 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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[int] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # TrackSource enum values
|
||||||
|
hidden: bool | None = None
|
||||||
|
recorder: bool | None = None
|
||||||
|
can_update_metadata: bool | None = None
|
||||||
|
agent: bool | None = None
|
||||||
|
can_subscribe_metrics: bool | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||||
"""Validate participant update data."""
|
"""Validate participant update data."""
|
||||||
|
|
||||||
@@ -264,10 +336,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant attributes as JSON object",
|
help_text="Participant attributes as JSON object",
|
||||||
)
|
)
|
||||||
permission = serializers.DictField(
|
permission = SchemaField(
|
||||||
|
schema=ParticipantPermission | None,
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant permission as JSON object",
|
help_text="Participant permissions",
|
||||||
)
|
)
|
||||||
name = serializers.CharField(
|
name = serializers.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -277,6 +350,33 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
help_text="Display name for the participant",
|
help_text="Display name for the participant",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_permission(self, permission):
|
||||||
|
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
||||||
|
|
||||||
|
if permission is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
suspicious_fields = [
|
||||||
|
field
|
||||||
|
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
||||||
|
if getattr(permission, field) is not None
|
||||||
|
]
|
||||||
|
if suspicious_fields:
|
||||||
|
raise SuspiciousOperation(
|
||||||
|
f"Setting the following participant permissions is not allowed: "
|
||||||
|
f"{', '.join(suspicious_fields)}."
|
||||||
|
)
|
||||||
|
if permission.can_subscribe_metrics is not None:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{
|
||||||
|
"permission": {
|
||||||
|
"can_subscribe_metrics": "This permission is not implemented."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return permission
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Ensure at least one update field is provided."""
|
"""Ensure at least one update field is provided."""
|
||||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||||
@@ -292,12 +392,144 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
f"{', '.join(update_fields)}."
|
f"{', '.join(update_fields)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
if "permission" in attrs:
|
return attrs
|
||||||
try:
|
|
||||||
ParticipantPermission(**attrs["permission"])
|
|
||||||
except ValueError as e:
|
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(
|
raise serializers.ValidationError(
|
||||||
{"permission": f"Invalid permission: {str(e)}"}
|
{"filename": _("This file extension is not allowed.")},
|
||||||
) from e
|
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
|
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.")
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from 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 (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
@@ -22,19 +32,18 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core import enums, models, utils
|
from core import enums, models, utils
|
||||||
|
from core.api.filters import ListFileFilter
|
||||||
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
from core.recording.event.authentication import StorageEventAuthentication
|
from core.recording.event.authentication import StorageEventAuthentication
|
||||||
from core.recording.event.exceptions import (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
InvalidFileTypeError,
|
InvalidFileTypeError,
|
||||||
ParsingEventDataError,
|
ParsingEventDataError,
|
||||||
)
|
)
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
from core.recording.event.parsers import get_parser
|
from core.recording.event.parsers import get_parser
|
||||||
from core.recording.services.metadata_extractor import (
|
|
||||||
MetadataExtractorException,
|
|
||||||
MetadataExtractorService,
|
|
||||||
)
|
|
||||||
from core.recording.worker.exceptions import (
|
from core.recording.worker.exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
@@ -60,9 +69,10 @@ from core.services.participants_management import (
|
|||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
from core.services.room_creation import RoomCreation
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
@@ -81,20 +91,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
|
|||||||
lookup_fields: list[str] = ["pk"]
|
lookup_fields: list[str] = ["pk"]
|
||||||
lookup_url_kwargs: list[str] = []
|
lookup_url_kwargs: list[str] = []
|
||||||
|
|
||||||
def __getattribute__(self, item):
|
def __getattribute__(self, file):
|
||||||
"""
|
"""
|
||||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||||
"""
|
"""
|
||||||
if item in ["lookup_field", "lookup_url_kwarg"]:
|
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||||
return getattr(self, item + "s", [None])[-1]
|
return getattr(self, file + "s", [None])[-1]
|
||||||
|
|
||||||
return super().__getattribute__(item)
|
return super().__getattribute__(file)
|
||||||
|
|
||||||
def get_queryset(self):
|
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.
|
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||||
"""
|
"""
|
||||||
@@ -195,18 +205,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(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
@@ -217,6 +215,7 @@ class RoomViewSet(
|
|||||||
API endpoints to access and perform actions on rooms.
|
API endpoints to access and perform actions on rooms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
pagination_class = Pagination
|
||||||
permission_classes = [permissions.RoomPermissions]
|
permission_classes = [permissions.RoomPermissions]
|
||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
@@ -312,10 +311,15 @@ class RoomViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
# May raise exception if an active or initiated recording already exist for the room
|
# May raise exception if an active or initiated recording already exist for the room
|
||||||
recording = models.Recording.objects.create(room=room, mode=mode)
|
recording = models.Recording.objects.create(
|
||||||
|
room=room,
|
||||||
|
mode=mode,
|
||||||
|
options=options.model_dump(exclude_none=True) if options else {},
|
||||||
|
)
|
||||||
|
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||||
@@ -332,15 +336,6 @@ class RoomViewSet(
|
|||||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
|
||||||
settings.ROOM_METADATA_EXTRACTOR_ENABLED
|
|
||||||
and recording.mode == models.RecordingModeChoices.TRANSCRIPT
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
MetadataExtractorService().start(recording)
|
|
||||||
except MetadataExtractorException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"message": f"Recording successfully started for room {room.slug}"},
|
{"message": f"Recording successfully started for room {room.slug}"},
|
||||||
status=drf_status.HTTP_201_CREATED,
|
status=drf_status.HTTP_201_CREATED,
|
||||||
@@ -389,7 +384,10 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="request-entry",
|
url_path="request-entry",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
throttle_classes=[
|
||||||
|
throttling.RequestEntryAuthenticatedUserRateThrottle,
|
||||||
|
throttling.RequestEntryAnonRateThrottle,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
"""Request entry to a room"""
|
"""Request entry to a room"""
|
||||||
@@ -499,7 +497,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="creation-callback",
|
url_path="creation-callback",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
|
||||||
)
|
)
|
||||||
def creation_callback(self, request):
|
def creation_callback(self, request):
|
||||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||||
@@ -629,13 +627,15 @@ class RoomViewSet(
|
|||||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
permission = serializer.validated_data.get("permission")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ParticipantsManagement().update(
|
ParticipantsManagement().update(
|
||||||
room_name=str(room.pk),
|
room_name=str(room.pk),
|
||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
metadata=serializer.validated_data.get("metadata"),
|
metadata=serializer.validated_data.get("metadata"),
|
||||||
attributes=serializer.validated_data.get("attributes"),
|
attributes=serializer.validated_data.get("attributes"),
|
||||||
permission=serializer.validated_data.get("permission"),
|
permission=permission.model_dump() if permission else None,
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
)
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
@@ -762,9 +762,14 @@ class RecordingViewSet(
|
|||||||
except InvalidBucketError as e:
|
except InvalidBucketError as e:
|
||||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||||
|
|
||||||
except InvalidFileTypeError as e:
|
except InvalidFilepathError:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"message": f"Ignore this file type, {e}"},
|
{"message": "Notification ignored."},
|
||||||
|
)
|
||||||
|
|
||||||
|
except InvalidFileTypeError:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": "Notification ignored."},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -811,7 +816,7 @@ class RecordingViewSet(
|
|||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
logger.debug("Original url: '%s'", original_url)
|
logger.debug("Original url: '%s'", original_url)
|
||||||
@@ -828,7 +833,7 @@ class RecordingViewSet(
|
|||||||
try:
|
try:
|
||||||
return match.groupdict()
|
return match.groupdict()
|
||||||
except (ValueError, AttributeError) as exc:
|
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
|
raise drf_exceptions.PermissionDenied() from exc
|
||||||
|
|
||||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||||
@@ -852,7 +857,7 @@ class RecordingViewSet(
|
|||||||
recording_id = url_params["recording_id"]
|
recording_id = url_params["recording_id"]
|
||||||
|
|
||||||
extension = url_params["extension"]
|
extension = url_params["extension"]
|
||||||
if extension not in [item.value for item in FileExtension]:
|
if extension not in [file.value for file in FileExtension]:
|
||||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -876,3 +881,324 @@ class RecordingViewSet(
|
|||||||
request = utils.generate_s3_authorization_headers(recording.key)
|
request = utils.generate_s3_authorization_headers(recording.key)
|
||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-public-methods
|
||||||
|
class FileViewSet(
|
||||||
|
SerializerPerActionMixin,
|
||||||
|
mixins.CreateModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
FileViewSet API.
|
||||||
|
|
||||||
|
This viewset provides CRUD operations and additional actions for managing files.
|
||||||
|
|
||||||
|
### API Endpoints:
|
||||||
|
1. **List**: Retrieve a paginated list of files.
|
||||||
|
Example: GET /files/?page=2
|
||||||
|
2. **Retrieve**: Get a specific file by its ID.
|
||||||
|
Example: GET /files/{id}/
|
||||||
|
3. **Create**: Create a new file.
|
||||||
|
Example: POST /files/
|
||||||
|
4. **Update**: Update a file by its ID.
|
||||||
|
Example: PUT /files/{id}/
|
||||||
|
5. **Delete**: Soft delete a file by its ID.
|
||||||
|
Example: DELETE /files/{id}/
|
||||||
|
|
||||||
|
|
||||||
|
### Ordering: created_at, updated_at, title
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Ascending: GET /api/v1.0/files/?ordering=created_at
|
||||||
|
|
||||||
|
### Filtering:
|
||||||
|
- `is_creator_me=true`: Returns files created by the current user.
|
||||||
|
- `is_creator_me=false`: Returns files created by other users.
|
||||||
|
- `is_deleted=false`: Returns files that are not (soft) deleted
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- GET /api/v1.0/files/?is_creator_me=true
|
||||||
|
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
|
||||||
|
|
||||||
|
### Notes:
|
||||||
|
- Implements soft delete logic to retain file
|
||||||
|
"""
|
||||||
|
|
||||||
|
ordering = ["-updated_at"]
|
||||||
|
ordering_fields = ["created_at", "updated_at", "title"]
|
||||||
|
pagination_class = Pagination
|
||||||
|
permission_classes = [
|
||||||
|
permissions.FilePermission,
|
||||||
|
]
|
||||||
|
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
|
||||||
|
default_serializer_class = serializers.FileSerializer
|
||||||
|
serializer_classes = {
|
||||||
|
"list": serializers.ListFileSerializer,
|
||||||
|
"create": serializers.CreateFileSerializer,
|
||||||
|
}
|
||||||
|
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
|
||||||
|
filterset_class = ListFileFilter
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Get queryset that defaults to the current request user."""
|
||||||
|
user = self.request.user
|
||||||
|
queryset = super().get_queryset().select_related("creator")
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# For now, we force the filtering on the current user in all cases, might evolve later
|
||||||
|
queryset = queryset.filter(creator=user)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_response_for_queryset(self, queryset, context=None):
|
||||||
|
"""Return paginated response for the queryset if requested."""
|
||||||
|
context = context or self.get_serializer_context()
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True, context=context)
|
||||||
|
result = self.get_paginated_response(serializer.data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
serializer = self.get_serializer(queryset, many=True, context=context)
|
||||||
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Set the current user as creator of the newly created file."""
|
||||||
|
|
||||||
|
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||||
|
file_type = serializer.validated_data["type"]
|
||||||
|
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
|
||||||
|
|
||||||
|
count = models.File.objects.filter(
|
||||||
|
creator=self.request.user,
|
||||||
|
deleted_at__isnull=True,
|
||||||
|
type=file_type,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if count >= config_for_file_type["max_count_by_user"]:
|
||||||
|
logger.info(
|
||||||
|
"create_item: user reached max files per user for type %s",
|
||||||
|
file_type,
|
||||||
|
)
|
||||||
|
raise serializers.PermissionDenied(
|
||||||
|
_("You have reached the maximum number of files for this type.")
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer.save(creator=self.request.user)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Override to implement a soft delete instead of dumping the record in database."""
|
||||||
|
instance.soft_delete()
|
||||||
|
|
||||||
|
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||||
|
@FeatureFlag.require("file_upload")
|
||||||
|
def upload_ended(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Check the actual uploaded file and mark it as ready.
|
||||||
|
"""
|
||||||
|
|
||||||
|
file = self.get_object()
|
||||||
|
|
||||||
|
if not file.is_pending_upload:
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"file": "This action is only available for files in PENDING state."},
|
||||||
|
code="file_upload_state_not_pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
|
head_response = s3_client.head_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||||
|
)
|
||||||
|
file_size = head_response["ContentLength"]
|
||||||
|
|
||||||
|
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||||
|
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||||
|
if file_size > config_for_file_type["max_size"]:
|
||||||
|
self._complete_file_deletion(file)
|
||||||
|
logger.info(
|
||||||
|
"upload_ended: file size (%s) for file %s higher than the allowed max size",
|
||||||
|
file_size,
|
||||||
|
file.file_key,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
detail="The file size is higher than the allowed max size.",
|
||||||
|
code="file_size_exceeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
# python-magic recommends using at least the first 2048 bytes
|
||||||
|
# to reduce incorrect identification.
|
||||||
|
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
|
||||||
|
# of the file for mime type identification.
|
||||||
|
if file_size > 2048:
|
||||||
|
range_response = s3_client.get_object(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=file.file_key,
|
||||||
|
Range="bytes=0-2047",
|
||||||
|
)
|
||||||
|
file_head = range_response["Body"].read()
|
||||||
|
else:
|
||||||
|
file_head = s3_client.get_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||||
|
)["Body"].read()
|
||||||
|
|
||||||
|
# Use improved MIME type detection combining magic bytes and file extension
|
||||||
|
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
|
||||||
|
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
|
||||||
|
|
||||||
|
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||||
|
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||||
|
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
|
||||||
|
if mimetype not in allowed_file_mimetypes:
|
||||||
|
self._complete_file_deletion(file)
|
||||||
|
logger.warning(
|
||||||
|
"upload_ended: mimetype not allowed %s for file %s",
|
||||||
|
mimetype,
|
||||||
|
file.file_key,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
detail="The file type is not allowed.",
|
||||||
|
code="file_type_not_allowed",
|
||||||
|
)
|
||||||
|
|
||||||
|
file.upload_state = models.FileUploadStateChoices.READY
|
||||||
|
file.mimetype = mimetype
|
||||||
|
file.size = file_size
|
||||||
|
|
||||||
|
file.save(update_fields=["upload_state", "mimetype", "size"])
|
||||||
|
|
||||||
|
if head_response["ContentType"] != mimetype:
|
||||||
|
logger.info(
|
||||||
|
"upload_ended: content type mismatch between object storage and file,"
|
||||||
|
" updating from %s to %s",
|
||||||
|
head_response["ContentType"],
|
||||||
|
mimetype,
|
||||||
|
)
|
||||||
|
s3_client.copy_object(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=file.file_key,
|
||||||
|
CopySource={
|
||||||
|
"Bucket": default_storage.bucket_name,
|
||||||
|
"Key": file.file_key,
|
||||||
|
},
|
||||||
|
ContentType=mimetype,
|
||||||
|
Metadata=head_response["Metadata"],
|
||||||
|
MetadataDirective="REPLACE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Not yet implemented
|
||||||
|
# Change the file.upload_state when this will be done
|
||||||
|
# malware_detection.analyse_file(file.file_key, file_id=file.id)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(file)
|
||||||
|
|
||||||
|
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def _complete_file_deletion(self, file):
|
||||||
|
"""Delete a file completely."""
|
||||||
|
file.soft_delete()
|
||||||
|
file.hard_delete()
|
||||||
|
process_file_deletion.delay(file.id)
|
||||||
|
|
||||||
|
def _authorize_subrequest(self, request, pattern):
|
||||||
|
"""
|
||||||
|
Authorize access based on the original URL of an Nginx subrequest
|
||||||
|
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||||
|
|
||||||
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
|
is configured to do this.
|
||||||
|
|
||||||
|
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
||||||
|
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||||
|
a 403 error). Note that we return 403 errors without any further details for security
|
||||||
|
reasons.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- pattern: The regex pattern to extract identifiers from the URL.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- A dictionary of URL parameters if the request is authorized.
|
||||||
|
Raises:
|
||||||
|
- PermissionDenied if authorization fails.
|
||||||
|
"""
|
||||||
|
# Extract the original URL from the request header
|
||||||
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
|
if not original_url:
|
||||||
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
parsed_url = urlparse(original_url)
|
||||||
|
match = pattern.search(unquote(parsed_url.path))
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
logger.warning(
|
||||||
|
"Subrequest URL '%s' did not match pattern '%s'",
|
||||||
|
parsed_url.path,
|
||||||
|
pattern,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
try:
|
||||||
|
url_params = match.groupdict()
|
||||||
|
except (ValueError, AttributeError) as exc:
|
||||||
|
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
||||||
|
raise drf_exceptions.PermissionDenied() from exc
|
||||||
|
|
||||||
|
pk = url_params.get("pk")
|
||||||
|
if not pk:
|
||||||
|
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
# Fetch the file and check if the user has access
|
||||||
|
queryset = models.File.objects.all()
|
||||||
|
# No suspicious analysis implemented yet
|
||||||
|
# queryset = self._filter_suspicious_files(queryset, request.user)
|
||||||
|
try:
|
||||||
|
file = queryset.get(pk=pk)
|
||||||
|
except models.File.DoesNotExist as exc:
|
||||||
|
logger.warning("File with ID '%s' does not exist", pk)
|
||||||
|
raise drf_exceptions.PermissionDenied() from exc
|
||||||
|
|
||||||
|
user_abilities = file.get_abilities(request.user)
|
||||||
|
if not user_abilities.get(self.action, False):
|
||||||
|
logger.warning(
|
||||||
|
"User '%s' lacks permission for file '%s'", request.user.id, pk
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Subrequest authorization successful. Extracted parameters: %s", url_params
|
||||||
|
)
|
||||||
|
return url_params, request.user.id, file
|
||||||
|
|
||||||
|
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||||
|
@FeatureFlag.require("file_upload")
|
||||||
|
def media_auth(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
This view is used by an Nginx subrequest to control access to an file's
|
||||||
|
attachment file.
|
||||||
|
|
||||||
|
When we let the request go through, we compute authorization headers that will be added to
|
||||||
|
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||||
|
annotation. The request will then be proxied to the object storage backend who will
|
||||||
|
respond with the file after checking the signature included in headers.
|
||||||
|
"""
|
||||||
|
url_params, _, file = self._authorize_subrequest(
|
||||||
|
request, MEDIA_STORAGE_URL_PATTERN
|
||||||
|
)
|
||||||
|
|
||||||
|
if file.is_pending_upload:
|
||||||
|
logger.warning("File '%s' is not ready", file.id)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
# Generate S3 authorization headers using the extracted URL parameters
|
||||||
|
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||||
|
|
||||||
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
raise exceptions.AuthenticationFailed("Token missing user identity")
|
raise exceptions.AuthenticationFailed("Token missing user identity")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = UserModel.objects.get(id=user_id)
|
user = UserModel.objects.get(sub=user_id)
|
||||||
except UserModel.DoesNotExist:
|
except UserModel.DoesNotExist:
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
f"{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
|
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||||
# the choice of languages which should not be limited to the few languages active in
|
# the choice of languages which should not be limited to the few languages active in
|
||||||
# the app.
|
# the app.
|
||||||
|
|||||||
@@ -1,23 +1,51 @@
|
|||||||
"""Authentication Backends for external application to the Meet core app."""
|
"""Authentication Backends for external application to the Meet core app."""
|
||||||
|
|
||||||
|
# pylint: disable=R0913,R0917
|
||||||
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
import jwt
|
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
|
from core.models import Application
|
||||||
|
from core.services import jwt_token
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||||
"""JWT authentication for application-delegated API access.
|
"""Base JWT authentication class."""
|
||||||
|
|
||||||
Validates JWT tokens issued to applications that are acting on behalf
|
def __init__(
|
||||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
||||||
"""
|
):
|
||||||
|
"""Initialize the JWT authentication backend with the given token service configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret_key: Secret key for JWT encoding/decoding
|
||||||
|
algorithm: JWT algorithm (e.g. HS256)
|
||||||
|
issuer: Expected token issuer identifier
|
||||||
|
audience: Expected token audience identifier
|
||||||
|
expiration_seconds: Token expiration time in seconds
|
||||||
|
token_type: Token type (e.g. Bearer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._token_service = jwt_token.JwtTokenService(
|
||||||
|
secret_key=secret_key,
|
||||||
|
algorithm=algorithm,
|
||||||
|
issuer=issuer,
|
||||||
|
audience=audience,
|
||||||
|
expiration_seconds=expiration_seconds,
|
||||||
|
token_type=token_type,
|
||||||
|
)
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Extract and validate JWT from Authorization header.
|
"""Extract and validate JWT from Authorization header.
|
||||||
@@ -25,9 +53,11 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (user, payload) if authentication successful, None otherwise
|
Tuple of (user, payload) if authentication successful, None otherwise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
auth_header = authentication.get_authorization_header(request).split()
|
auth_header = authentication.get_authorization_header(request).split()
|
||||||
|
|
||||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||||
|
# Defer to next authentication backend
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(auth_header) != 2:
|
if len(auth_header) != 2:
|
||||||
@@ -42,56 +72,62 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
|
|
||||||
return self.authenticate_credentials(token)
|
return self.authenticate_credentials(token)
|
||||||
|
|
||||||
def authenticate_credentials(self, token):
|
def decode_jwt(self, token):
|
||||||
"""Validate JWT token and return authenticated user.
|
"""Decode and validate JWT token.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: JWT token string
|
token: JWT token string
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (user, payload)
|
Decoded payload dict, or None if token is invalid
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
AuthenticationFailed: If token is invalid, expired, or user not found
|
AuthenticationFailed: If token is expired or has invalid issuer/audience
|
||||||
"""
|
"""
|
||||||
# Decode and validate JWT
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = self._token_service.decode_jwt(token)
|
||||||
token,
|
return payload
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
except jwt_token.TokenExpiredError as e:
|
||||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
|
||||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
)
|
|
||||||
except jwt.ExpiredSignatureError as e:
|
|
||||||
logger.warning("Token expired")
|
logger.warning("Token expired")
|
||||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||||
except jwt.InvalidIssuerError as e:
|
except jwt_token.TokenInvalidError as e:
|
||||||
logger.warning("Invalid JWT issuer: %s", e)
|
logger.warning("Invalid JWT issuer or audience: %s", e)
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
|
||||||
except jwt.InvalidAudienceError as e:
|
|
||||||
logger.warning("Invalid JWT audience: %s", e)
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
|
||||||
except jwt.InvalidTokenError as e:
|
|
||||||
logger.warning("Invalid JWT token: %s", e)
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
|
except jwt_token.TokenDecodeError:
|
||||||
|
# Invalid JWT token - defer to next authentication backend
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate JWT payload claims.
|
||||||
|
|
||||||
|
Override in subclasses to add custom validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Decoded JWT payload
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If required claims are missing or invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_user(self, payload):
|
||||||
|
"""Retrieve and validate user from payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Decoded JWT payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If user not found or inactive
|
||||||
|
"""
|
||||||
user_id = payload.get("user_id")
|
user_id = payload.get("user_id")
|
||||||
client_id = payload.get("client_id")
|
|
||||||
is_delegated = payload.get("delegated", False)
|
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
logger.warning("Missing 'user_id' in JWT payload")
|
logger.warning("Missing 'user_id' in JWT payload")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
if not client_id:
|
|
||||||
logger.warning("Missing 'client_id' in JWT payload")
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
|
||||||
|
|
||||||
if not is_delegated:
|
|
||||||
logger.warning("Token is not marked as delegated")
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
except User.DoesNotExist as e:
|
except User.DoesNotExist as e:
|
||||||
@@ -102,8 +138,129 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||||
|
|
||||||
return (user, payload)
|
return user
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return authentication scheme for WWW-Authenticate header."""
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
return "Bearer"
|
return "Bearer"
|
||||||
|
|
||||||
|
def authenticate_credentials(self, token):
|
||||||
|
"""Validate JWT token and return authenticated user.
|
||||||
|
|
||||||
|
If token is invalid, defer to next authentication backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (user, payload)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If token is expired, or user not found
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = self.decode_jwt(token)
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.validate_payload(payload)
|
||||||
|
user = self.get_user(payload)
|
||||||
|
|
||||||
|
return (user, payload)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||||
|
"""JWT authentication for application-delegated API access.
|
||||||
|
|
||||||
|
Validates JWT tokens issued to applications that are acting on behalf
|
||||||
|
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize authentication backend with application JWT settings from Django settings."""
|
||||||
|
super().__init__(
|
||||||
|
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||||
|
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate application-specific claims."""
|
||||||
|
client_id = payload.get("client_id")
|
||||||
|
is_delegated = payload.get("delegated", False)
|
||||||
|
|
||||||
|
if not client_id:
|
||||||
|
logger.warning("Missing 'client_id' in JWT payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(client_id=client_id)
|
||||||
|
except Application.DoesNotExist as e:
|
||||||
|
logger.warning("Application not found: %s", client_id)
|
||||||
|
raise exceptions.AuthenticationFailed("Application not found.") from e
|
||||||
|
|
||||||
|
if not application.is_active:
|
||||||
|
logger.warning(
|
||||||
|
"Inactive application attempted authentication: %s", client_id
|
||||||
|
)
|
||||||
|
raise exceptions.AuthenticationFailed("Application is disabled.")
|
||||||
|
|
||||||
|
if not is_delegated:
|
||||||
|
logger.warning("Token is not marked as delegated")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
|
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||||
|
|
||||||
|
def get_or_create_user(self, access_token, id_token, payload):
|
||||||
|
"""Get or create user from OIDC token claims.
|
||||||
|
|
||||||
|
Despite the LaSuiteBackend's method name suggesting "get_or_create",
|
||||||
|
its implementation only performs a GET operation.
|
||||||
|
Create new user from the sub claim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: The access token string
|
||||||
|
id_token: The ID token string (unused)
|
||||||
|
payload: Token payload dict (unused)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SuspiciousOperation: If user info validation fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
sub = payload.get("sub")
|
||||||
|
|
||||||
|
if sub is None:
|
||||||
|
message = "User info contained no recognizable user identification"
|
||||||
|
logger.debug(message)
|
||||||
|
raise SuspiciousOperation(message)
|
||||||
|
|
||||||
|
user = self.get_user(access_token, id_token, payload)
|
||||||
|
|
||||||
|
if user is None and settings.OIDC_CREATE_USER:
|
||||||
|
user = self.create_user(sub)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_user(self, sub):
|
||||||
|
"""Create new user from subject claim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sub: Subject identifier from token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Newly created User instance
|
||||||
|
"""
|
||||||
|
user = self.UserModel(sub=sub)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from rest_framework import exceptions, permissions
|
from rest_framework import exceptions, permissions
|
||||||
|
|
||||||
from .. import models
|
from .. import models
|
||||||
@@ -31,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
Raises:
|
Raises:
|
||||||
PermissionDenied: If required scope is missing from token
|
PermissionDenied: If required scope is missing from token
|
||||||
"""
|
"""
|
||||||
# Get the current action (e.g., 'list', 'create')
|
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||||
action = getattr(view, "action", None)
|
action = getattr(view, "action", None)
|
||||||
if not action:
|
if not action:
|
||||||
raise exceptions.PermissionDenied(
|
# DRF routers return a 405 for unsupported methods
|
||||||
"Insufficient permissions. Unknown action."
|
return True
|
||||||
)
|
|
||||||
|
|
||||||
required_scope = self.scope_map.get(action)
|
required_scope = self.scope_map.get(action)
|
||||||
if not required_scope:
|
if not required_scope:
|
||||||
@@ -55,6 +56,15 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
if isinstance(token_scopes, str):
|
if isinstance(token_scopes, str):
|
||||||
token_scopes = token_scopes.split()
|
token_scopes = token_scopes.split()
|
||||||
|
|
||||||
|
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||||
|
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||||
|
|
||||||
|
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||||
|
token_scopes = [
|
||||||
|
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||||
|
for scope in token_scopes
|
||||||
|
]
|
||||||
|
|
||||||
if required_scope not in token_scopes:
|
if required_scope not in token_scopes:
|
||||||
raise exceptions.PermissionDenied(
|
raise exceptions.PermissionDenied(
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
@@ -74,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
||||||
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RoomPermissions(permissions.BasePermission):
|
||||||
|
"""Permissions applying to the room API endpoint."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Allow access only to authenticated users."""
|
||||||
|
return request.user.is_authenticated
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if request.method in permissions.SAFE_METHODS:
|
||||||
|
return obj.has_any_role(user)
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
return obj.is_owner(user)
|
||||||
|
|
||||||
|
return obj.is_administrator_or_owner(user)
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"""External API endpoints"""
|
"""External API endpoints"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
|
||||||
import jwt
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework import decorators, mixins, viewsets
|
from rest_framework import decorators, mixins, viewsets
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
@@ -21,13 +20,14 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core import api, models
|
from core import api, models
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
|
||||||
from . import authentication, permissions, serializers
|
from . import authentication, permissions, serializers
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationViewSet(viewsets.GenericViewSet):
|
class ApplicationViewSet(viewsets.ViewSet):
|
||||||
"""API endpoints for application authentication and token generation."""
|
"""API endpoints for application authentication and token generation."""
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
@@ -61,12 +61,12 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
|||||||
except models.Application.DoesNotExist as e:
|
except models.Application.DoesNotExist as e:
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
||||||
|
|
||||||
if not application.active:
|
|
||||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
|
||||||
|
|
||||||
if not check_password(client_secret, application.client_secret):
|
if not check_password(client_secret, application.client_secret):
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||||
|
|
||||||
|
if not application.is_active:
|
||||||
|
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
||||||
|
|
||||||
email = serializer.validated_data["scope"]
|
email = serializer.validated_data["scope"]
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
@@ -92,41 +92,63 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = models.User.objects.get(email=email)
|
user = models.User.objects.get(email__iexact=email)
|
||||||
except models.User.DoesNotExist as e:
|
except models.User.DoesNotExist as e:
|
||||||
raise drf_exceptions.NotFound(
|
if (
|
||||||
{
|
settings.APPLICATION_ALLOW_USER_CREATION
|
||||||
"error": "User not found.",
|
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||||
}
|
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
|
||||||
|
):
|
||||||
|
# Create a provisional user without `sub`, identified by email only.
|
||||||
|
#
|
||||||
|
# This relies on Django LaSuite implicitly updating the `sub` field on the
|
||||||
|
# user's first successful OIDC authentication. If this stops working,
|
||||||
|
# check for behavior changes in Django LaSuite.
|
||||||
|
#
|
||||||
|
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
|
||||||
|
# updates. We override its default value to allow setting `sub` for
|
||||||
|
# provisional users.
|
||||||
|
user = models.User(
|
||||||
|
sub=None,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
logger.info(
|
||||||
|
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
|
||||||
|
user.id,
|
||||||
|
email,
|
||||||
|
application.client_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise drf_exceptions.NotFound("User not found.") from e
|
||||||
|
except models.User.MultipleObjectsReturned as e:
|
||||||
|
raise SuspiciousOperation(
|
||||||
|
"Multiple user accounts share a common email."
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
scope = " ".join(application.scopes or [])
|
scope = " ".join(application.scopes or [])
|
||||||
|
|
||||||
payload = {
|
token_service = JwtTokenService(
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
|
||||||
"client_id": client_id,
|
|
||||||
"scope": scope,
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"delegated": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
token = jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||||
|
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = token_service.generate_jwt(
|
||||||
|
user,
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
"client_id": client_id,
|
||||||
|
"delegated": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{
|
data,
|
||||||
"access_token": token,
|
|
||||||
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
|
|
||||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
|
||||||
"scope": scope,
|
|
||||||
},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
status=drf_status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,9 +171,14 @@ class RoomViewSet(
|
|||||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
authentication_classes = [authentication.ApplicationJWTAuthentication]
|
authentication_classes = [
|
||||||
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
api.permissions.IsAuthenticated
|
||||||
|
& permissions.HasRequiredRoomScope
|
||||||
|
& permissions.RoomPermissions
|
||||||
]
|
]
|
||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
import factory.fuzzy
|
import factory.fuzzy
|
||||||
@@ -126,7 +129,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
|
|||||||
model = models.Application
|
model = models.Application
|
||||||
|
|
||||||
name = factory.Faker("company")
|
name = factory.Faker("company")
|
||||||
active = True
|
is_active = True
|
||||||
client_id = factory.LazyFunction(utils.generate_client_id)
|
client_id = factory.LazyFunction(utils.generate_client_id)
|
||||||
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
||||||
scopes = []
|
scopes = []
|
||||||
@@ -153,3 +156,42 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
domain = factory.Faker("domain_name")
|
domain = factory.Faker("domain_name")
|
||||||
application = factory.SubFactory(ApplicationFactory)
|
application = factory.SubFactory(ApplicationFactory)
|
||||||
|
|
||||||
|
|
||||||
|
class FileFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""A factory to create files"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.File
|
||||||
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
|
title = factory.Sequence(lambda n: f"file{n}")
|
||||||
|
creator = factory.SubFactory(UserFactory)
|
||||||
|
deleted_at = None
|
||||||
|
type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices])
|
||||||
|
filename = factory.lazy_attribute(lambda o: fake.file_name())
|
||||||
|
upload_state = None
|
||||||
|
size = None
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def update_upload_state(self, create, extracted, **kwargs):
|
||||||
|
"""Change the upload state of a file."""
|
||||||
|
if create and extracted:
|
||||||
|
self.upload_state = extracted
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def upload_bytes(self, create, extracted, **kwargs):
|
||||||
|
"""Save content of the file into the storage"""
|
||||||
|
if create and extracted is not None:
|
||||||
|
content = (
|
||||||
|
extracted
|
||||||
|
if isinstance(extracted, bytes)
|
||||||
|
else str(extracted).encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.filename = kwargs.get("filename", self.filename or "content.txt")
|
||||||
|
self.size = len(content)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
default_storage.save(self.file_key, BytesIO(content))
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ class Migration(migrations.Migration):
|
|||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='resource',
|
model_name='resource',
|
||||||
name='users',
|
name='users',
|
||||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
|
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='resourceaccess',
|
model_name='resourceaccess',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
name='language',
|
name='language',
|
||||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
name='language',
|
name='language',
|
||||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-29 15:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0015_application_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recording',
|
||||||
|
name='options',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Recording options', verbose_name='Recording options'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-03 15:22
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0016_recording_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='File',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||||
|
('type', models.CharField(choices=[('background_image', 'Background image')], max_length=25)),
|
||||||
|
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('hard_deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('filename', models.CharField(max_length=255)),
|
||||||
|
('upload_state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready')], max_length=25)),
|
||||||
|
('mimetype', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('size', models.BigIntegerField(blank=True, null=True)),
|
||||||
|
('description', models.TextField(blank=True, null=True)),
|
||||||
|
('malware_detection_info', models.JSONField(blank=True, default=dict, help_text='Malware detection info when the analysis status is unsafe.', null=True)),
|
||||||
|
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='files_created', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'File',
|
||||||
|
'verbose_name_plural': 'Files',
|
||||||
|
'db_table': 'file',
|
||||||
|
'ordering': ('created_at',),
|
||||||
|
'indexes': [models.Index(fields=['creator', 'type', '-created_at'], name='file_creator_730cce_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-11 14:39
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0017_file'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='application',
|
||||||
|
old_name='active',
|
||||||
|
new_name='is_active',
|
||||||
|
),
|
||||||
|
]
|
||||||
+200
-3
@@ -1,11 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Declare and configure the models for the Meet core application
|
Declare and configure the models for the Meet core application
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from os.path import splitext
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -14,7 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
|||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core import mail, validators
|
from django.core import mail, validators
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -146,7 +149,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
sub = models.CharField(
|
sub = models.CharField(
|
||||||
_("sub"),
|
_("sub"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"Optional for pending users; required upon account activation. "
|
||||||
|
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
),
|
),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -291,6 +295,10 @@ class Resource(BaseModel):
|
|||||||
role = RoleChoices.MEMBER
|
role = RoleChoices.MEMBER
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
def has_any_role(self, user):
|
||||||
|
"""Check if a user has any role on the resource."""
|
||||||
|
return self.get_role(user) is not None
|
||||||
|
|
||||||
def is_administrator_or_owner(self, user):
|
def is_administrator_or_owner(self, user):
|
||||||
"""
|
"""
|
||||||
Check if a user is administrator or owner of the resource."""
|
Check if a user is administrator or owner of the resource."""
|
||||||
@@ -576,6 +584,12 @@ class Recording(BaseModel):
|
|||||||
verbose_name=_("Recording mode"),
|
verbose_name=_("Recording mode"),
|
||||||
help_text=_("Defines the mode of recording being called."),
|
help_text=_("Defines the mode of recording being called."),
|
||||||
)
|
)
|
||||||
|
options = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
verbose_name=_("Recording options"),
|
||||||
|
help_text=_("Recording options"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_recording"
|
db_table = "meet_recording"
|
||||||
@@ -745,7 +759,7 @@ class Application(BaseModel):
|
|||||||
verbose_name=_("Application name"),
|
verbose_name=_("Application name"),
|
||||||
help_text=_("Descriptive name for this application."),
|
help_text=_("Descriptive name for this application."),
|
||||||
)
|
)
|
||||||
active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
client_id = models.CharField(
|
client_id = models.CharField(
|
||||||
max_length=100, unique=True, default=utils.generate_client_id
|
max_length=100, unique=True, default=utils.generate_client_id
|
||||||
)
|
)
|
||||||
@@ -818,3 +832,186 @@ class ApplicationDomain(BaseModel):
|
|||||||
|
|
||||||
self.domain = self.domain.lower().strip()
|
self.domain = self.domain.lower().strip()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadStateChoices(models.TextChoices):
|
||||||
|
"""Possible states of a file."""
|
||||||
|
|
||||||
|
PENDING = "pending", _("Pending")
|
||||||
|
# Commented out for now, as we may need this when we implement the malware detection logic.
|
||||||
|
# ANALYZING = "analyzing", _("Analyzing")
|
||||||
|
# SUSPICIOUS = "suspicious", _("Suspicious")
|
||||||
|
# FILE_TOO_LARGE_TO_ANALYZE = (
|
||||||
|
# "file_too_large_to_analyze",
|
||||||
|
# _("File too large to analyze"),
|
||||||
|
# )
|
||||||
|
READY = "ready", _("Ready")
|
||||||
|
|
||||||
|
|
||||||
|
class FileTypeChoices(models.TextChoices):
|
||||||
|
"""Defines the possible types of a file."""
|
||||||
|
|
||||||
|
BACKGROUND_IMAGE = "background_image", _("Background image")
|
||||||
|
|
||||||
|
|
||||||
|
class File(BaseModel):
|
||||||
|
"""File uploaded by a user."""
|
||||||
|
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=25,
|
||||||
|
choices=FileTypeChoices.choices,
|
||||||
|
null=False,
|
||||||
|
blank=False,
|
||||||
|
)
|
||||||
|
title = models.CharField(_("title"), max_length=255)
|
||||||
|
creator = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.RESTRICT,
|
||||||
|
related_name="files_created",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
hard_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
filename = models.CharField(max_length=255, null=False, blank=False)
|
||||||
|
|
||||||
|
upload_state = models.CharField(
|
||||||
|
max_length=25,
|
||||||
|
choices=FileUploadStateChoices.choices,
|
||||||
|
)
|
||||||
|
mimetype = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
size = models.BigIntegerField(null=True, blank=True)
|
||||||
|
description = models.TextField(null=True, blank=True)
|
||||||
|
malware_detection_info = models.JSONField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
help_text=_("Malware detection info when the analysis status is unsafe."),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "file"
|
||||||
|
verbose_name = _("File")
|
||||||
|
verbose_name_plural = _("Files")
|
||||||
|
ordering = ("created_at",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["creator", "type", "-created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.title)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Set the upload state to pending if it's the first save and it's a file."""
|
||||||
|
|
||||||
|
if self.created_at is None:
|
||||||
|
self.upload_state = FileUploadStateChoices.PENDING
|
||||||
|
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, using=None, keep_parents=False):
|
||||||
|
if self.deleted_at is None:
|
||||||
|
raise RuntimeError("The file must be soft deleted before being deleted.")
|
||||||
|
|
||||||
|
return super().delete(using, keep_parents)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pending_upload(self):
|
||||||
|
"""Return whether the file is in a pending upload state"""
|
||||||
|
return self.upload_state == FileUploadStateChoices.PENDING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extension(self):
|
||||||
|
"""Return the extension related to the filename."""
|
||||||
|
if self.filename is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The file must have a filename to compute its extension."
|
||||||
|
)
|
||||||
|
|
||||||
|
_, extension = splitext(self.filename)
|
||||||
|
|
||||||
|
if extension:
|
||||||
|
return extension.lstrip(".")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_base(self):
|
||||||
|
"""Key base of the location where the file is stored in object storage."""
|
||||||
|
if not self.pk:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The file instance must be saved before requesting a storage key."
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_key(self):
|
||||||
|
"""Key used to store the file in object storage."""
|
||||||
|
_, extension = splitext(self.filename)
|
||||||
|
# We store only the extension in the storage system to avoid
|
||||||
|
# leaking Personal Information in logs, etc.
|
||||||
|
return f"{self.key_base}{extension!s}"
|
||||||
|
|
||||||
|
def get_abilities(self, user):
|
||||||
|
"""
|
||||||
|
Compute and return abilities for a given user on the file.
|
||||||
|
"""
|
||||||
|
# Characteristics that are based only on specific access
|
||||||
|
is_creator = user == self.creator
|
||||||
|
retrieve = is_creator
|
||||||
|
is_deleted = self.deleted_at is not None
|
||||||
|
can_update = is_creator and not is_deleted and user.is_authenticated
|
||||||
|
can_hard_delete = is_creator and user.is_authenticated
|
||||||
|
can_destroy = can_hard_delete and not is_deleted
|
||||||
|
|
||||||
|
return {
|
||||||
|
"destroy": can_destroy,
|
||||||
|
"hard_delete": can_hard_delete,
|
||||||
|
"retrieve": retrieve,
|
||||||
|
"media_auth": retrieve and not is_deleted,
|
||||||
|
"partial_update": can_update,
|
||||||
|
"update": can_update,
|
||||||
|
"upload_ended": can_update and user.is_authenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def soft_delete(self):
|
||||||
|
"""
|
||||||
|
Soft delete the file.
|
||||||
|
We still keep the .delete() method untouched for programmatic purposes.
|
||||||
|
"""
|
||||||
|
if self.deleted_at:
|
||||||
|
raise RuntimeError("This file is already deleted.")
|
||||||
|
|
||||||
|
self.deleted_at = timezone.now()
|
||||||
|
self.save(update_fields=["deleted_at"])
|
||||||
|
|
||||||
|
def hard_delete(self):
|
||||||
|
"""
|
||||||
|
Hard delete the file.
|
||||||
|
We still keep the .delete() method untouched for programmatic purposes.
|
||||||
|
"""
|
||||||
|
if self.hard_deleted_at:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"hard_deleted_at": ValidationError(
|
||||||
|
_("This file is already hard deleted."),
|
||||||
|
code="file_hard_delete_already_effective",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.deleted_at is None:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"hard_deleted_at": ValidationError(
|
||||||
|
_("To hard delete a file, it must first be soft deleted."),
|
||||||
|
code="file_hard_delete_should_soft_delete_first",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.hard_deleted_at = timezone.now()
|
||||||
|
self.save(update_fields=["hard_deleted_at"])
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ from core import models
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_recording_download_base_url() -> str:
|
||||||
|
"""Get the recording download base URL with backward compatibility."""
|
||||||
|
new_setting = settings.RECORDING_DOWNLOAD_BASE_URL
|
||||||
|
old_setting = settings.SCREEN_RECORDING_BASE_URL
|
||||||
|
|
||||||
|
if old_setting:
|
||||||
|
logger.warning(
|
||||||
|
"SCREEN_RECORDING_BASE_URL is deprecated and will be removed in a future version. "
|
||||||
|
"Please use RECORDING_DOWNLOAD_BASE_URL instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_setting:
|
||||||
|
return new_setting
|
||||||
|
|
||||||
|
return old_setting
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
"""Service for processing recordings and notifying external services."""
|
"""Service for processing recordings and notifying external services."""
|
||||||
|
|
||||||
@@ -26,7 +43,12 @@ class NotificationService:
|
|||||||
return self._notify_summary_service(recording)
|
return self._notify_summary_service(recording)
|
||||||
|
|
||||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||||
return self._notify_user_by_email(recording)
|
summary_success = True
|
||||||
|
if recording.options.get("transcribe", False):
|
||||||
|
summary_success = self._notify_summary_service(recording)
|
||||||
|
|
||||||
|
email_success = self._notify_user_by_email(recording)
|
||||||
|
return email_success and summary_success
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Unknown recording mode %s for recording %s",
|
"Unknown recording mode %s for recording %s",
|
||||||
@@ -64,7 +86,7 @@ class NotificationService:
|
|||||||
"domain": settings.EMAIL_DOMAIN,
|
"domain": settings.EMAIL_DOMAIN,
|
||||||
"room_name": recording.room.name,
|
"room_name": recording.room.name,
|
||||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
has_failures = False
|
has_failures = False
|
||||||
@@ -137,12 +159,15 @@ class NotificationService:
|
|||||||
"email": owner_access.user.email,
|
"email": owner_access.user.email,
|
||||||
"sub": owner_access.user.sub,
|
"sub": owner_access.user.sub,
|
||||||
"room": recording.room.name,
|
"room": recording.room.name,
|
||||||
|
"language": recording.options.get("language"),
|
||||||
"recording_date": recording.created_at.astimezone(
|
"recording_date": recording.created_at.astimezone(
|
||||||
owner_access.user.timezone
|
owner_access.user.timezone
|
||||||
).strftime("%Y-%m-%d"),
|
).strftime("%Y-%m-%d"),
|
||||||
"recording_time": recording.created_at.astimezone(
|
"recording_time": recording.created_at.astimezone(
|
||||||
owner_access.user.timezone
|
owner_access.user.timezone
|
||||||
).strftime("%H:%M"),
|
).strftime("%H:%M"),
|
||||||
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
|
"context_language": owner_access.user.language,
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@@ -158,9 +183,9 @@ class NotificationService:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.HTTPError as exc:
|
except requests.RequestException as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
|
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||||
recording.id,
|
recording.id,
|
||||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
exc,
|
exc,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from typing import Any, Dict, Optional, Protocol
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from core.enums import FILE_EXT_REGEX, UUID_REGEX
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
@@ -86,7 +88,7 @@ class MinioParser:
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
self._filepath_regex = re.compile(
|
self._filepath_regex = re.compile(
|
||||||
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
"""Wip."""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from livekit.protocol.agent_dispatch import (
|
|
||||||
CreateAgentDispatchRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core import utils
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataExtractorException(Exception):
|
|
||||||
"""Wip."""
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataExtractorService:
|
|
||||||
"""Wip."""
|
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def start(self, recording):
|
|
||||||
"""Wip."""
|
|
||||||
|
|
||||||
lkapi = utils.create_livekit_client()
|
|
||||||
room_id = str(recording.room.id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await lkapi.agent_dispatch.create_dispatch(
|
|
||||||
CreateAgentDispatchRequest(
|
|
||||||
agent_name=settings.ROOM_METADATA_EXTRACTOR_AGENT_NAME,
|
|
||||||
room=room_id,
|
|
||||||
metadata=str(recording.id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to create metadata extractor agent for room %s", room_id
|
|
||||||
)
|
|
||||||
raise MetadataExtractorException(
|
|
||||||
"Failed to create metadata extractor agent"
|
|
||||||
) from e
|
|
||||||
finally:
|
|
||||||
await lkapi.aclose()
|
|
||||||
|
|
||||||
dispatch_id = getattr(response, "id", None)
|
|
||||||
|
|
||||||
if not dispatch_id:
|
|
||||||
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
|
|
||||||
raise MetadataExtractorException(
|
|
||||||
f"LiveKit did not return a dispatch_id for room {room_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return dispatch_id
|
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def stop(self, recording):
|
|
||||||
"""Wip."""
|
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
|
||||||
lkapi = utils.create_livekit_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
dispatches = await lkapi.agent_dispatch.list_dispatch(room_name=room_name)
|
|
||||||
|
|
||||||
dispatch_id = next(
|
|
||||||
(
|
|
||||||
d.id
|
|
||||||
for d in dispatches
|
|
||||||
if d.agent_name == settings.ROOM_METADATA_EXTRACTOR_AGENT_NAME
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not dispatch_id:
|
|
||||||
logger.warning(
|
|
||||||
"No metadata extractor agent found for room %s", room_name
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
await lkapi.agent_dispatch.delete_dispatch(
|
|
||||||
dispatch_id=str(dispatch_id), room_name=room_name
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to stop metadata extractor agent dispatch for room %s",
|
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
raise MetadataExtractorException(
|
|
||||||
f"Failed to stop metadata metadata extractor agent for room {room_name}"
|
|
||||||
) from e
|
|
||||||
finally:
|
|
||||||
await lkapi.aclose()
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Recording-related LiveKit Events Service"""
|
"""Recording-related LiveKit Events Service"""
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
|
from livekit import api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -12,7 +16,28 @@ class RecordingEventsError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class RecordingEventsService:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related Livekit webhook events."""
|
"""Handles recording-related LiveKit webhook events."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_update(recording, egress_status):
|
||||||
|
"""Handle egress status updates and sync recording state to room metadata."""
|
||||||
|
|
||||||
|
room_name = str(recording.room.id)
|
||||||
|
|
||||||
|
status_mapping = {
|
||||||
|
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||||
|
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||||
|
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||||
|
}
|
||||||
|
|
||||||
|
recording_status = status_mapping.get(egress_status)
|
||||||
|
if recording_status:
|
||||||
|
try:
|
||||||
|
utils.update_room_metadata(
|
||||||
|
room_name, {"recording_status": recording_status}
|
||||||
|
)
|
||||||
|
except utils.MetadataUpdateException as e:
|
||||||
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_limit_reached(recording):
|
def handle_limit_reached(recording):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from core import utils
|
||||||
from core.models import Recording, RecordingStatusChoices
|
from core.models import Recording, RecordingStatusChoices
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
@@ -60,6 +61,15 @@ class WorkerServiceMediator:
|
|||||||
finally:
|
finally:
|
||||||
recording.save()
|
recording.save()
|
||||||
|
|
||||||
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
|
try:
|
||||||
|
utils.update_room_metadata(
|
||||||
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
|
)
|
||||||
|
except utils.MetadataUpdateException as e:
|
||||||
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Worker started for room %s (worker ID: %s)",
|
"Worker started for room %s (worker ID: %s)",
|
||||||
recording.room,
|
recording.room,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""JWT token service."""
|
||||||
|
|
||||||
|
# pylint: disable=R0913,R0917
|
||||||
|
# ruff: noqa: PLR0913
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
|
||||||
|
class JWTError(Exception):
|
||||||
|
"""Base exception for all JWT token errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenExpiredError(JWTError):
|
||||||
|
"""Raised when the JWT token has expired."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInvalidError(JWTError):
|
||||||
|
"""Raised when the JWT token has an invalid issuer or audience."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenDecodeError(JWTError):
|
||||||
|
"""Raised for any other unrecoverable JWT decode failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class JwtTokenService:
|
||||||
|
"""Generic JWT token service with configurable settings."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
secret_key: str,
|
||||||
|
algorithm: str,
|
||||||
|
issuer: str,
|
||||||
|
audience: str,
|
||||||
|
expiration_seconds: int,
|
||||||
|
token_type: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the token service with custom settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret_key: Secret key for JWT encoding/decoding
|
||||||
|
algorithm: JWT algorithm
|
||||||
|
issuer: Token issuer identifier
|
||||||
|
audience: Token audience identifier
|
||||||
|
expiration_seconds: Token expiration time in seconds
|
||||||
|
token_type: Token type
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImproperlyConfigured: If secret_key is None or empty
|
||||||
|
"""
|
||||||
|
if not secret_key:
|
||||||
|
raise ImproperlyConfigured("Secret key is required.")
|
||||||
|
if not algorithm:
|
||||||
|
raise ImproperlyConfigured("Algorithm is required.")
|
||||||
|
if not token_type:
|
||||||
|
raise ImproperlyConfigured("Token's type is required.")
|
||||||
|
if expiration_seconds is None:
|
||||||
|
raise ImproperlyConfigured("Expiration's seconds is required.")
|
||||||
|
|
||||||
|
self._key = secret_key
|
||||||
|
self._algorithm = algorithm
|
||||||
|
self._issuer = issuer
|
||||||
|
self._audience = audience
|
||||||
|
self._expiration_seconds = expiration_seconds
|
||||||
|
self._token_type = token_type
|
||||||
|
|
||||||
|
def generate_jwt(
|
||||||
|
self, user, scope: str, extra_payload: Optional[dict] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Generate an access token for the given user.
|
||||||
|
|
||||||
|
Note: any extra_payload variables named iat, exp, or user_id will
|
||||||
|
be overwritten by this service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance for whom to generate the token
|
||||||
|
scope: Space-separated scope string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing access_token, token_type, expires_in, and scope optionally
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = extra_payload.copy() if extra_payload else {}
|
||||||
|
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=self._expiration_seconds),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._issuer:
|
||||||
|
payload["iss"] = self._issuer
|
||||||
|
if self._audience:
|
||||||
|
payload["aud"] = self._audience
|
||||||
|
if scope:
|
||||||
|
payload["scope"] = scope
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
payload,
|
||||||
|
self._key,
|
||||||
|
algorithm=self._algorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": self._token_type,
|
||||||
|
"expires_in": self._expiration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope:
|
||||||
|
response["scope"] = scope
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def decode_jwt(self, token):
|
||||||
|
"""Decode and validate JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded payload dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TokenExpiredError: If the token has expired.
|
||||||
|
TokenInvalidError: If the token has an invalid issuer or audience.
|
||||||
|
TokenDecodeError: If the token is malformed or cannot be decoded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
self._key,
|
||||||
|
algorithms=[self._algorithm],
|
||||||
|
issuer=self._issuer,
|
||||||
|
audience=self._audience,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError as e:
|
||||||
|
raise TokenExpiredError("Token expired.") from e
|
||||||
|
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
|
||||||
|
raise TokenInvalidError("Invalid token.") from e
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
raise TokenDecodeError("Token decode error.") from e
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
@@ -10,11 +11,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models
|
from core import models, utils
|
||||||
from core.recording.services.metadata_extractor import (
|
|
||||||
MetadataExtractorException,
|
|
||||||
MetadataExtractorService,
|
|
||||||
)
|
|
||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
@@ -96,6 +93,17 @@ class LiveKitEventsService:
|
|||||||
self.telephony_service = TelephonyService()
|
self.telephony_service = TelephonyService()
|
||||||
self.recording_events = RecordingEventsService()
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
|
self._filter_regex = None
|
||||||
|
if settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX:
|
||||||
|
try:
|
||||||
|
self._filter_regex = re.compile(
|
||||||
|
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX
|
||||||
|
)
|
||||||
|
except re.error:
|
||||||
|
logger.exception(
|
||||||
|
"Invalid LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX. Webhook filtering disabled."
|
||||||
|
)
|
||||||
|
|
||||||
def receive(self, request):
|
def receive(self, request):
|
||||||
"""Process webhook and route to appropriate handler."""
|
"""Process webhook and route to appropriate handler."""
|
||||||
|
|
||||||
@@ -110,6 +118,12 @@ class LiveKitEventsService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise InvalidPayloadError("Invalid webhook payload") from e
|
raise InvalidPayloadError("Invalid webhook payload") from e
|
||||||
|
|
||||||
|
room_name = data.room.name or data.egress_info.room_name
|
||||||
|
|
||||||
|
if self._filter_regex and not self._filter_regex.search(room_name):
|
||||||
|
logger.info("Filtered webhook event for room '%s'", room_name)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_type = LiveKitWebhookEventType(data.event)
|
webhook_type = LiveKitWebhookEventType(data.event)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -126,11 +140,25 @@ class LiveKitEventsService:
|
|||||||
# pylint: disable=not-callable
|
# pylint: disable=not-callable
|
||||||
handler(data)
|
handler(data)
|
||||||
|
|
||||||
|
def _handle_egress_updated(self, data):
|
||||||
|
"""Handle 'egress_updated' event."""
|
||||||
|
|
||||||
|
egress_id = data.egress_info.egress_id
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(worker_id=egress_id)
|
||||||
|
except models.Recording.DoesNotExist as err:
|
||||||
|
raise ActionFailedError(
|
||||||
|
f"Recording with worker ID {egress_id} does not exist"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
egress_status = data.egress_info.status
|
||||||
|
self.recording_events.handle_update(recording, egress_status)
|
||||||
|
|
||||||
def _handle_egress_ended(self, data):
|
def _handle_egress_ended(self, data):
|
||||||
"""Handle 'egress_ended' event."""
|
"""Handle 'egress_ended' event."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recording = models.Recording.objects.select_related("room").get(
|
recording = models.Recording.objects.get(
|
||||||
worker_id=data.egress_info.egress_id
|
worker_id=data.egress_info.egress_id
|
||||||
)
|
)
|
||||||
except models.Recording.DoesNotExist as err:
|
except models.Recording.DoesNotExist as err:
|
||||||
@@ -138,14 +166,13 @@ class LiveKitEventsService:
|
|||||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
if (
|
try:
|
||||||
settings.ROOM_METADATA_EXTRACTOR_ENABLED
|
room_name = str(recording.room.id)
|
||||||
and recording.mode == models.RecordingModeChoices.TRANSCRIPT
|
utils.update_room_metadata(
|
||||||
):
|
room_name, {}, ["recording_mode", "recording_status"]
|
||||||
try:
|
)
|
||||||
MetadataExtractorService().stop(recording)
|
except utils.MetadataUpdateException as e:
|
||||||
except MetadataExtractorException:
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
pass
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class LobbyService:
|
|||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
"""Request entry to a room for a participant.
|
"""Request entry to a room for a participant.
|
||||||
|
|
||||||
This usual status transitions is:
|
The usual status transitions are:
|
||||||
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# ruff: noqa: PLC0415
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def task(*d_args, **d_kwargs):
|
||||||
|
"""
|
||||||
|
Decorator compatible with Celery's @app.task, but works without Celery.
|
||||||
|
|
||||||
|
If Celery is available, returns a real Celery task.
|
||||||
|
If not, returns the original function and provides `.delay()`/`.apply_async()`
|
||||||
|
as synchronous fallbacks (so existing call sites don't break).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Mostly LLM-generated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _fallback_wrap(func):
|
||||||
|
def delay(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
def apply_async(args=None, kwargs=None, **_options):
|
||||||
|
return func(*(args or ()), **(kwargs or {}))
|
||||||
|
|
||||||
|
func.delay = delay
|
||||||
|
func.apply_async = apply_async
|
||||||
|
return func
|
||||||
|
|
||||||
|
# Handle bare decorator usage: @task
|
||||||
|
if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs:
|
||||||
|
func = d_args[0]
|
||||||
|
if settings.CELERY_ENABLED:
|
||||||
|
from meet.celery_app import app as _celery_app
|
||||||
|
|
||||||
|
return _celery_app.task(func)
|
||||||
|
return _fallback_wrap(func)
|
||||||
|
|
||||||
|
# Handle parameterized usage: @task(...), e.g. @task(bind=True)
|
||||||
|
def _decorate(func):
|
||||||
|
if settings.CELERY_ENABLED:
|
||||||
|
from meet.celery_app import app as _celery_app
|
||||||
|
|
||||||
|
return _celery_app.task(*d_args, **d_kwargs)(func)
|
||||||
|
return _fallback_wrap(func)
|
||||||
|
|
||||||
|
return _decorate
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("task",)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Tasks related to files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
|
from core.models import File
|
||||||
|
from core.tasks._task import task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def process_file_deletion(file_id):
|
||||||
|
"""
|
||||||
|
Process the deletion of a file.
|
||||||
|
Definitely delete it in the database.
|
||||||
|
Delete the files from the storage.
|
||||||
|
"""
|
||||||
|
logger.info("Processing item deletion for %s", file_id)
|
||||||
|
try:
|
||||||
|
file = File.objects.get(id=file_id)
|
||||||
|
except File.DoesNotExist:
|
||||||
|
logger.error("Item %s does not exist", file_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if file.hard_deleted_at is None:
|
||||||
|
logger.error("To process an item deletion, it must be hard deleted first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Deleting file %s", file.file_key)
|
||||||
|
default_storage.delete(file.file_key)
|
||||||
|
|
||||||
|
file.delete()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Custom template tags for the core application of People."""
|
"""Custom template tags for the core application of Meet."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
|||||||
@@ -345,7 +345,9 @@ def test_authentication_getter_existing_user_change_fields(
|
|||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
# One and only one additional update query when a field has changed
|
# One and only one additional update query when a field has changed
|
||||||
with django_assert_num_queries(2):
|
# Note: .save() triggers uniqueness validation queries for unique fields,
|
||||||
|
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
|
||||||
|
with django_assert_num_queries(3):
|
||||||
authenticated_user = klass.get_or_create_user(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
Tests for files API endpoint in meet's core app: create
|
||||||
|
"""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
from core.models import File, FileTypeChoices, FileUploadStateChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_anonymous():
|
||||||
|
"""Anonymous users should not be allowed to create items."""
|
||||||
|
response = APIClient().post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"title": "My file",
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
assert not File.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_authenticated_success():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to create files and should automatically be declared
|
||||||
|
as the owner of the newly created file.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"title": "my file",
|
||||||
|
"filename": "my_file.png",
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201, response.json()
|
||||||
|
file = File.objects.get()
|
||||||
|
assert file.title == "my file"
|
||||||
|
assert file.creator == user
|
||||||
|
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
assert file.upload_state == FileUploadStateChoices.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_no_filename():
|
||||||
|
"""
|
||||||
|
Creating a file item without providing a filename should fail.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"title": "my item",
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"filename": ["This field is required."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_success():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to create a file file and must provide a filename.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
with freeze_time(now):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"title": "Eiffle tower",
|
||||||
|
"filename": "file.png",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
file = File.objects.get()
|
||||||
|
assert file.title == "Eiffle tower"
|
||||||
|
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
assert file.filename == "file.png"
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
assert response_data["creator"] is not None, response_data
|
||||||
|
|
||||||
|
assert response.json().get("policy") is not None
|
||||||
|
|
||||||
|
policy = response.json()["policy"]
|
||||||
|
policy_parsed = urlparse(policy)
|
||||||
|
|
||||||
|
assert policy_parsed.scheme == "http"
|
||||||
|
assert policy_parsed.netloc == "localhost:9000"
|
||||||
|
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||||
|
|
||||||
|
query_params = parse_qs(policy_parsed.query)
|
||||||
|
|
||||||
|
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
|
||||||
|
assert query_params.pop("X-Amz-Credential") == [
|
||||||
|
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
|
||||||
|
]
|
||||||
|
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
|
||||||
|
assert query_params.pop("X-Amz-Expires") == ["60"]
|
||||||
|
assert query_params.pop("X-Amz-SignedHeaders") == ["host;x-amz-acl"]
|
||||||
|
assert query_params.pop("X-Amz-Signature") is not None
|
||||||
|
|
||||||
|
assert len(query_params) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_extension_not_allowed():
|
||||||
|
"""
|
||||||
|
Creating a file item with an extension not allowed should fail.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"title": "Paris tower",
|
||||||
|
"filename": "file.notallowed",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"filename": ["This file extension is not allowed."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_extension_case_insensitive():
|
||||||
|
"""
|
||||||
|
Creating a file item with an extension, no matter the case used, should be allowed.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "file.JPG",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.json()
|
||||||
|
file = File.objects.get()
|
||||||
|
assert file.title == "file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_disabled(settings):
|
||||||
|
"""
|
||||||
|
Creating a file is denied if file upload is disabled
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_ENABLED = False
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "file.JPG",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert not File.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_not_checking_extension(settings):
|
||||||
|
"""
|
||||||
|
Creating a file with an extension not allowed should not fail when restrictions are disabled.
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "file.notallowed",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.json()
|
||||||
|
file = File.objects.get()
|
||||||
|
assert file.title == "file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_no_extension_but_checking_it_should_fail(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Creating a file without an extension but checking the extension should fail.
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "file",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"filename": ["This file extension is not allowed."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_should_fail(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Creating a hidden file (starting with a dot) but checking the extension should fail.
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": ".file",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"filename": ["This file extension is not allowed."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_file_too_many(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Creating a file is forbidden if above user limit.
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||||
|
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||||
|
"background_image": {
|
||||||
|
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||||
|
"max_count_by_user": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "1.png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "2.png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "You have reached the maximum number of files for this type."
|
||||||
|
}
|
||||||
|
assert File.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_force_id_success():
|
||||||
|
"""It should be possible to force the item ID when creating a item."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
forced_id = uuid4()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"id": str(forced_id),
|
||||||
|
"title": "my item",
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "my_file.png",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201, response.json()
|
||||||
|
files = File.objects.all()
|
||||||
|
assert len(files) == 1
|
||||||
|
assert files[0].id == forced_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_create_force_id_existing():
|
||||||
|
"""
|
||||||
|
It should not be possible to use the ID of an existing file when forcing ID on creation.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"id": str(file.id),
|
||||||
|
"title": "my file",
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "my_file.png",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"id": ["A file with this ID already exists. You cannot override it."]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_api_files_create_file_race_condition():
|
||||||
|
"""
|
||||||
|
It should be possible to create several files at the same time
|
||||||
|
without causing any race conditions or data integrity issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_item(title):
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
return client.post(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"filename": "my_file.png",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
future1 = executor.submit(create_item, "my item 1")
|
||||||
|
future2 = executor.submit(create_item, "my item 2")
|
||||||
|
|
||||||
|
response1 = future1.result()
|
||||||
|
response2 = future2.result()
|
||||||
|
|
||||||
|
assert response1.status_code == 201
|
||||||
|
assert response2.status_code == 201
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Tests for files API endpoint in meet's core app: delete
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_delete_anonymous():
|
||||||
|
"""Anonymous users should not be allowed to destroy a file."""
|
||||||
|
file = factories.FileFactory()
|
||||||
|
existing_items = models.File.objects.all().count()
|
||||||
|
|
||||||
|
response = APIClient().delete(
|
||||||
|
f"/api/v1.0/files/{file.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert models.File.objects.count() == existing_items
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_delete_authenticated_owner():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to delete a item they own.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(creator=user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/files/{file.id}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Make sure it is only a soft delete
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is not None
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
Tests for files API endpoint in meet's core app: list
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from faker import Faker
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
fake = Faker()
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_anonymous_not_allowed():
|
||||||
|
"""
|
||||||
|
Anonymous users should not be allowed to list files whatever the
|
||||||
|
"""
|
||||||
|
response = APIClient().get("/api/v1.0/files/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_authentificated_user_allowed():
|
||||||
|
"""
|
||||||
|
Authentificated users should be allowed to list files
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/files/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {"count": 0, "next": None, "previous": None, "results": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_format():
|
||||||
|
"""Validate the format of files as returned by the list view."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
title="item 1",
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A file from another user should not appear
|
||||||
|
factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
title="item 2",
|
||||||
|
)
|
||||||
|
|
||||||
|
# hard deleted item should not appear
|
||||||
|
factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
hard_deleted_at=timezone.now(),
|
||||||
|
title="hard deleted item",
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/files/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
results = content.pop("results")
|
||||||
|
assert content == {
|
||||||
|
"count": 1,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
}
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results == [
|
||||||
|
{
|
||||||
|
"id": str(file.id),
|
||||||
|
"created_at": file.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
"creator": {
|
||||||
|
"id": str(file.creator.id),
|
||||||
|
"full_name": file.creator.full_name,
|
||||||
|
"short_name": file.creator.short_name,
|
||||||
|
},
|
||||||
|
"title": file.title,
|
||||||
|
"updated_at": file.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
"type": models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
"upload_state": file.upload_state,
|
||||||
|
"url": None,
|
||||||
|
"mimetype": file.mimetype,
|
||||||
|
"filename": file.filename,
|
||||||
|
"size": None,
|
||||||
|
"description": None,
|
||||||
|
"deleted_at": None,
|
||||||
|
"hard_deleted_at": None,
|
||||||
|
"abilities": {
|
||||||
|
"destroy": True,
|
||||||
|
"hard_delete": True,
|
||||||
|
"media_auth": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"upload_ended": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||||
|
def test_api_files_list_pagination(
|
||||||
|
_mock_page_size,
|
||||||
|
):
|
||||||
|
"""Pagination should work as expected."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file_ids = [
|
||||||
|
str(file.id)
|
||||||
|
for file in factories.FileFactory.create_batch(
|
||||||
|
3,
|
||||||
|
creator=user,
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Get page 1
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/files/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
assert content["count"] == 3
|
||||||
|
assert content["next"] == "http://testserver/api/v1.0/files/?page=2"
|
||||||
|
assert content["previous"] is None
|
||||||
|
|
||||||
|
assert len(content["results"]) == 2
|
||||||
|
for item in content["results"]:
|
||||||
|
file_ids.remove(item["id"])
|
||||||
|
|
||||||
|
# Get page 2
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/files/?page=2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
assert content["count"] == 3
|
||||||
|
assert content["next"] is None
|
||||||
|
assert content["previous"] == "http://testserver/api/v1.0/files/"
|
||||||
|
|
||||||
|
assert len(content["results"]) == 1
|
||||||
|
for item in content["results"]:
|
||||||
|
file_ids.remove(item["id"])
|
||||||
|
assert file_ids == []
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Tests for files API endpoint in meet's core app: list
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from faker import Faker
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
fake = Faker()
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
# Filters: unknown field
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_filter_unknown_field():
|
||||||
|
"""
|
||||||
|
Trying to filter by an unknown field should do nothing.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.FileFactory(type=models.FileTypeChoices.BACKGROUND_IMAGE)
|
||||||
|
expected_ids = {
|
||||||
|
str(file.id)
|
||||||
|
for file in factories.FileFactory.create_batch(
|
||||||
|
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/files/?unknown=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 2
|
||||||
|
assert {result["id"] for result in results} == expected_ids
|
||||||
|
|
||||||
|
|
||||||
|
# Filters: is_creator_me
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_filter_is_creator_me_true():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to filter files they created.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.FileFactory.create_batch(
|
||||||
|
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
)
|
||||||
|
factories.FileFactory.create_batch(2, type=models.FileTypeChoices.BACKGROUND_IMAGE)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/files/?is_creator_me=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
# Ensure all results are created by the current user
|
||||||
|
for result in results:
|
||||||
|
assert result["creator"] == {
|
||||||
|
"id": str(user.id),
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"short_name": user.short_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_filter_is_creator_me_invalid():
|
||||||
|
"""Filtering with an invalid `is_creator_me` value should do nothing."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.FileFactory.create_batch(
|
||||||
|
3, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/files/?is_creator_me=invalid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# Filters: type
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_filter_type_and_upload_status():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to filter files by their type and upload status.
|
||||||
|
|
||||||
|
This test will make more sense when other types are added to the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
creator=user,
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
)
|
||||||
|
assert file.upload_state == models.FileUploadStateChoices.PENDING
|
||||||
|
|
||||||
|
expected_files = factories.FileFactory.create_batch(
|
||||||
|
2,
|
||||||
|
creator=user,
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
)
|
||||||
|
expected_files_ids = {str(file.id) for file in expected_files}
|
||||||
|
|
||||||
|
# Filter by type: background_image & upload state
|
||||||
|
response = client.get("/api/v1.0/files/?type=background_image&upload_state=ready")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["count"] == 2
|
||||||
|
|
||||||
|
results = response.json()["results"]
|
||||||
|
|
||||||
|
# Ensure all results are background images
|
||||||
|
results_ids = {result["id"] for result in results}
|
||||||
|
assert results_ids == expected_files_ids
|
||||||
|
for result in results:
|
||||||
|
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
assert result["upload_state"] == models.FileUploadStateChoices.READY
|
||||||
|
|
||||||
|
# Second request without the upload_state filter, to check that all 3 show up
|
||||||
|
response = client.get("/api/v1.0/files/?type=background_image")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["count"] == 3
|
||||||
|
|
||||||
|
results = response.json()["results"]
|
||||||
|
|
||||||
|
# Ensure all results are background images
|
||||||
|
results_ids = {result["id"] for result in results}
|
||||||
|
assert results_ids == {str(file.id) for file in expected_files + [file]}
|
||||||
|
for result in results:
|
||||||
|
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_filter_is_deleted():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to filter files by their deletion status.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
not_deleted_file = factories.FileFactory(creator=user)
|
||||||
|
|
||||||
|
deleted_files = factories.FileFactory.create_batch(2, creator=user)
|
||||||
|
for file in deleted_files:
|
||||||
|
file.soft_delete()
|
||||||
|
|
||||||
|
# No filters
|
||||||
|
response_no_filters = client.get("/api/v1.0/files/")
|
||||||
|
|
||||||
|
assert response_no_filters.status_code == 200
|
||||||
|
assert response_no_filters.json()["count"] == 3
|
||||||
|
|
||||||
|
results = response_no_filters.json()["results"]
|
||||||
|
|
||||||
|
results_ids = {result["id"] for result in results}
|
||||||
|
assert results_ids == {str(file.id) for file in [*deleted_files, not_deleted_file]}
|
||||||
|
|
||||||
|
# Filters deleted
|
||||||
|
response_filter_deleted = client.get("/api/v1.0/files/?is_deleted=true")
|
||||||
|
|
||||||
|
assert response_filter_deleted.status_code == 200
|
||||||
|
assert response_filter_deleted.json()["count"] == 2
|
||||||
|
|
||||||
|
results = response_filter_deleted.json()["results"]
|
||||||
|
|
||||||
|
results_ids = {result["id"] for result in results}
|
||||||
|
assert results_ids == {str(file.id) for file in deleted_files}
|
||||||
|
|
||||||
|
# Filters not deleted
|
||||||
|
response_filter_not_deleted = client.get("/api/v1.0/files/?is_deleted=false")
|
||||||
|
|
||||||
|
assert response_filter_not_deleted.status_code == 200
|
||||||
|
assert response_filter_not_deleted.json()["count"] == 1
|
||||||
|
|
||||||
|
results = response_filter_not_deleted.json()["results"]
|
||||||
|
|
||||||
|
# Ensure all results are deleted
|
||||||
|
results_ids = {result["id"] for result in results}
|
||||||
|
assert results_ids == {str(file.id) for file in [not_deleted_file]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_filter_unknown_type():
|
||||||
|
"""
|
||||||
|
Filtering by an unknown type should return an empty list
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.FileFactory.create_batch(3, creator=user)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/files/?type=unknown")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"type": ["Select a valid choice. unknown is not one of the available choices."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Test the ordering of items."""
|
||||||
|
|
||||||
|
import operator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_ordering_default():
|
||||||
|
"""items should be ordered by descending "updated_at" by default"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.FileFactory.create_batch(
|
||||||
|
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
)
|
||||||
|
response = client.get("/api/v1.0/files/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 4
|
||||||
|
|
||||||
|
# Check that results are sorted by descending "updated_at" as expected
|
||||||
|
for i in range(3):
|
||||||
|
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_list_ordering_by_fields():
|
||||||
|
"""It should be possible to order by several fields"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.FileFactory.create_batch(
|
||||||
|
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
for parameter in [
|
||||||
|
"created_at",
|
||||||
|
"-created_at",
|
||||||
|
"updated_at",
|
||||||
|
"-updated_at",
|
||||||
|
]:
|
||||||
|
is_descending = parameter.startswith("-")
|
||||||
|
field = parameter.lstrip("-")
|
||||||
|
querystring = f"?ordering={parameter}"
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/files/{querystring:s}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 4
|
||||||
|
|
||||||
|
# Check that results are sorted by the field in querystring as expected
|
||||||
|
compare = operator.ge if is_descending else operator.le
|
||||||
|
for i in range(3):
|
||||||
|
operator1 = (
|
||||||
|
results[i][field].lower()
|
||||||
|
if isinstance(results[i][field], str)
|
||||||
|
else results[i][field]
|
||||||
|
)
|
||||||
|
operator2 = (
|
||||||
|
results[i + 1][field].lower()
|
||||||
|
if isinstance(results[i + 1][field], str)
|
||||||
|
else results[i + 1][field]
|
||||||
|
)
|
||||||
|
assert compare(operator1, operator2)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Test file uploads API endpoint for users in meet's core app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_media_auth_anonymous_not_authorized():
|
||||||
|
"""Anonymous users should not be allowed to retrieve a file"""
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
)
|
||||||
|
|
||||||
|
original_url = f"http://localhost/media/{file.file_key:s}"
|
||||||
|
response = APIClient().get(
|
||||||
|
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_media_get_own():
|
||||||
|
"""
|
||||||
|
Authenticated user should be allowed to retrieve their own file.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
default_storage.save(
|
||||||
|
file.file_key,
|
||||||
|
BytesIO(b"my prose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
original_url = f"http://localhost/media/{file.file_key:s}"
|
||||||
|
now = timezone.now()
|
||||||
|
with freeze_time(now):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
authorization = response["Authorization"]
|
||||||
|
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||||
|
assert (
|
||||||
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
|
in authorization
|
||||||
|
)
|
||||||
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{file.file_key:s}"
|
||||||
|
response = requests.get(
|
||||||
|
file_url,
|
||||||
|
headers={
|
||||||
|
"authorization": authorization,
|
||||||
|
"x-amz-date": response["x-amz-date"],
|
||||||
|
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||||
|
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||||
|
},
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
assert response.content.decode("utf-8") == "my prose"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_media_auth_file_pending():
|
||||||
|
"""
|
||||||
|
Users who have a specific access to an file, whatever the role, should not be able to
|
||||||
|
retrieve related attachments if the file is not ready.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
key = file.file_key
|
||||||
|
|
||||||
|
original_url = quote(f"http://localhost/media/{key:s}")
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_media_auth_own_file_deleted():
|
||||||
|
"""
|
||||||
|
This function tests the access restrictions on deleted files through the media
|
||||||
|
authorization API endpoint. It ensures that a user cannot retrieve a file that is deleted.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
default_storage.save(
|
||||||
|
file.file_key,
|
||||||
|
BytesIO(b"my prose"),
|
||||||
|
)
|
||||||
|
file.soft_delete()
|
||||||
|
|
||||||
|
original_url = f"http://localhost/media/{file.file_key:s}"
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Tests for files API endpoint in meet's core app: update
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
from core.api import serializers
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_update_anonymous_forbidden():
|
||||||
|
"""
|
||||||
|
Anonymous users should not be allowed to update an file when link
|
||||||
|
configuration does not allow it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
file = factories.FileFactory()
|
||||||
|
|
||||||
|
old_file_values = serializers.FileSerializer(instance=file).data
|
||||||
|
new_file_values = serializers.FileSerializer(instance=factories.FileFactory()).data
|
||||||
|
|
||||||
|
response = APIClient().put(
|
||||||
|
f"/api/v1.0/files/{file.id!s}/",
|
||||||
|
new_file_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
item_values = serializers.FileSerializer(instance=file).data
|
||||||
|
assert item_values == old_file_values
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_files_update_description_and_title():
|
||||||
|
"""
|
||||||
|
Test the description and title of a file can be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
description="Old description",
|
||||||
|
title="Old title",
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/files/{file.id!s}/",
|
||||||
|
{"description": "New description", "title": "New title"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
assert result["description"] == "New description"
|
||||||
|
assert result["title"] == "New title"
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.description == "New description"
|
||||||
|
assert file.title == "New title"
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Test related to item upload ended API."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
from core.models import FileTypeChoices, FileUploadStateChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_file_upload_ended_anonymous():
|
||||||
|
"""Anonymous users should not be allowed to end an upload."""
|
||||||
|
file = factories.FileFactory()
|
||||||
|
response = APIClient().post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_file_upload_ended_non_creator_not_found():
|
||||||
|
"""Users without write permissions should not be allowed to end an upload."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory()
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_file_upload_ended_on_wrong_upload_state():
|
||||||
|
"""
|
||||||
|
Users should not be allowed to end an upload on files that are not in the PENDING upload state.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
creator=user,
|
||||||
|
update_upload_state=FileUploadStateChoices.READY,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"file": "This action is only available for files in PENDING state."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_file_upload_ended_success(settings):
|
||||||
|
"""
|
||||||
|
Users should be able to end an upload on files that are files and in the UPLOADING upload state.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||||
|
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||||
|
"background_image": {
|
||||||
|
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||||
|
"allowed_mimetypes": ["text/html", "text/plain"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
filename="my_file.txt",
|
||||||
|
mimetype="text/html",
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
default_storage.save(
|
||||||
|
file.file_key,
|
||||||
|
BytesIO(b"my prose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.upload_state == FileUploadStateChoices.READY
|
||||||
|
assert file.mimetype == "text/plain"
|
||||||
|
assert file.size == 8
|
||||||
|
|
||||||
|
assert response.json()["mimetype"] == "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
|
||||||
|
"""
|
||||||
|
Test that the API returns a 400 when the mimetype is not allowed.
|
||||||
|
File should be deleted and the file should be deleted from the storage.
|
||||||
|
"""
|
||||||
|
settings.RESTRICT_UPLOAD_FILE_TYPE = True
|
||||||
|
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||||
|
"background_image": {
|
||||||
|
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||||
|
"allowed_mimetypes": ["application/pdf"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||||
|
)
|
||||||
|
|
||||||
|
default_storage.save(
|
||||||
|
file.file_key,
|
||||||
|
BytesIO(b"my prose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (
|
||||||
|
f"upload_ended: mimetype not allowed text/plain for file {file.file_key}"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(id=file.id).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
|
||||||
|
"""
|
||||||
|
Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype.
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
|
||||||
|
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||||
|
"background_image": {
|
||||||
|
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||||
|
"allowed_mimetypes": ["application/pdf"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||||
|
)
|
||||||
|
|
||||||
|
default_storage.save(
|
||||||
|
file.file_key,
|
||||||
|
BytesIO(b"my prose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.upload_state == FileUploadStateChoices.READY
|
||||||
|
assert file.mimetype == "text/plain"
|
||||||
|
assert file.size == 8
|
||||||
|
|
||||||
|
assert response.json()["mimetype"] == "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog):
|
||||||
|
"""
|
||||||
|
Object on storage should have the same mimetype than the one saved in the
|
||||||
|
File object.
|
||||||
|
"""
|
||||||
|
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||||
|
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||||
|
"background_image": {
|
||||||
|
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||||
|
"allowed_mimetypes": ["text/html", "application/pdf"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
filename="my_file.pdf",
|
||||||
|
title="my_file.pdf",
|
||||||
|
creator=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
|
s3_client.put_object(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=file.file_key,
|
||||||
|
ContentType="text/html",
|
||||||
|
Body=BytesIO(
|
||||||
|
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
|
||||||
|
),
|
||||||
|
Metadata={
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
head_object = s3_client.head_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||||
|
)
|
||||||
|
|
||||||
|
assert head_object["ContentType"] == "text/html"
|
||||||
|
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
assert (
|
||||||
|
"upload_ended: content type mismatch between object storage and file,"
|
||||||
|
" updating from text/html to application/pdf" in caplog.text
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
|
||||||
|
assert file.mimetype == "application/pdf"
|
||||||
|
|
||||||
|
head_object = s3_client.head_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||||
|
)
|
||||||
|
assert head_object["ContentType"] == "application/pdf"
|
||||||
|
assert head_object["Metadata"] == {"foo": "bar"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_upload_ended_file_size_exceeded(settings, caplog):
|
||||||
|
"""
|
||||||
|
Test when the file size exceed the allowed max upload file size
|
||||||
|
should return a 400 and delete the file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||||
|
"background_image": {
|
||||||
|
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||||
|
"max_size": 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||||
|
)
|
||||||
|
|
||||||
|
default_storage.save(
|
||||||
|
file.file_key,
|
||||||
|
BytesIO(b"my prose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
|
||||||
|
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||||
|
assert (
|
||||||
|
f"upload_ended: file size (8) for file {file.file_key} higher than the allowed max size"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(id=file.id).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
@@ -60,6 +60,26 @@ def test_notify_external_services_screen_recording_mode(mock_notify_email):
|
|||||||
mock_notify_email.assert_called_once_with(recording)
|
mock_notify_email.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
|
||||||
|
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
|
||||||
|
def test_notify_external_services_screen_recording_mode_with_transcribe(
|
||||||
|
mock_notify_email, mock_notify_summary
|
||||||
|
):
|
||||||
|
"""Test notification routing for screen recording mode with transcribe option."""
|
||||||
|
|
||||||
|
service = NotificationService()
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(
|
||||||
|
mode=models.RecordingModeChoices.SCREEN_RECORDING, options={"transcribe": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.notify_external_services(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_notify_email.assert_called_once_with(recording)
|
||||||
|
mock_notify_summary.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
|
||||||
def test_notify_external_services_unknown_mode(caplog):
|
def test_notify_external_services_unknown_mode(caplog):
|
||||||
"""Test notification for unknown recording mode."""
|
"""Test notification for unknown recording mode."""
|
||||||
recording = factories.RecordingFactory()
|
recording = factories.RecordingFactory()
|
||||||
@@ -82,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
|||||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = None
|
||||||
settings.EMAIL_FROM = "notifications@acme.com"
|
settings.EMAIL_FROM = "notifications@acme.com"
|
||||||
|
|
||||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def valid_minio_event():
|
|||||||
"s3": {
|
"s3": {
|
||||||
"bucket": {"name": "test-bucket"},
|
"bucket": {"name": "test-bucket"},
|
||||||
"object": {
|
"object": {
|
||||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
"contentType": "audio/ogg",
|
"contentType": "audio/ogg",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
|
|||||||
"""Test parsing a valid Minio event."""
|
"""Test parsing a valid Minio event."""
|
||||||
event = minio_parser.parse(valid_minio_event)
|
event = minio_parser.parse(valid_minio_event)
|
||||||
assert isinstance(event, StorageEvent)
|
assert isinstance(event, StorageEvent)
|
||||||
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||||
assert event.filetype == "audio/ogg"
|
assert event.filetype == "audio/ogg"
|
||||||
assert event.bucket_name == "test-bucket"
|
assert event.bucket_name == "test-bucket"
|
||||||
assert event.metadata is None
|
assert event.metadata is None
|
||||||
@@ -130,11 +130,13 @@ def test_validate_invalid_filetype(minio_parser):
|
|||||||
"invalid_filepath",
|
"invalid_filepath",
|
||||||
[
|
[
|
||||||
"invalid_filepath", # totally invalid string
|
"invalid_filepath", # totally invalid string
|
||||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
||||||
"", # empty string
|
"", # empty string
|
||||||
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
|
||||||
|
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
|
||||||
|
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||||
@@ -152,7 +154,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
|||||||
def test_validate_valid_event(minio_parser):
|
def test_validate_valid_event(minio_parser):
|
||||||
"""Test validation with valid event data."""
|
"""Test validation with valid event data."""
|
||||||
event = StorageEvent(
|
event = StorageEvent(
|
||||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
filetype="audio/ogg",
|
filetype="audio/ogg",
|
||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
@@ -170,7 +172,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
|
|||||||
def test_validate_filepath_with_folder(minio_parser):
|
def test_validate_filepath_with_folder(minio_parser):
|
||||||
"""Test validation of filepath with folder structure."""
|
"""Test validation of filepath with folder structure."""
|
||||||
event = StorageEvent(
|
event = StorageEvent(
|
||||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
filetype="audio/ogg",
|
filetype="audio/ogg",
|
||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
@@ -219,7 +221,7 @@ def test_validate_custom_filetypes():
|
|||||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||||
|
|
||||||
event = StorageEvent(
|
event = StorageEvent(
|
||||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
filetype="audio/mp3",
|
filetype="audio/mp3",
|
||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def test_api_recordings_list_authenticated_direct(role, settings):
|
|||||||
"key": recording.key,
|
"key": recording.key,
|
||||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"mode": recording.mode,
|
"mode": recording.mode,
|
||||||
|
"options": {},
|
||||||
"room": {
|
"room": {
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user