Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 543ac20d66 | |||
| 1d42dd26ed |
@@ -4,7 +4,7 @@ __pycache__
|
|||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
venv
|
venv
|
||||||
**/.venv
|
.venv
|
||||||
|
|
||||||
# System-specific files
|
# System-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -24,15 +24,13 @@ data
|
|||||||
.cache
|
.cache
|
||||||
.circleci
|
.circleci
|
||||||
.git
|
.git
|
||||||
|
.vscode
|
||||||
.iml
|
.iml
|
||||||
|
.idea
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
.mypy_cache
|
||||||
.pylint.d
|
.pylint.d
|
||||||
|
.pytest_cache
|
||||||
**/.idea
|
|
||||||
**/.vscode
|
|
||||||
**/.pytest_cache
|
|
||||||
**/.mypy_cache
|
|
||||||
**/.ruff_cache
|
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
**/node_modules
|
node_modules
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download Crowdin files
|
- name: Download Crowdin files
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||||
@@ -23,18 +20,10 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -60,7 +49,6 @@ jobs:
|
|||||||
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 }}
|
||||||
@@ -68,18 +56,10 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-frontend-generic:
|
build-and-push-frontend-generic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -106,7 +86,6 @@ 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 }}
|
||||||
@@ -114,18 +93,10 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-frontend-dinum:
|
build-and-push-frontend-dinum:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -152,7 +123,6 @@ 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 }}
|
||||||
@@ -160,18 +130,10 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-summary:
|
build-and-push-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -190,7 +152,7 @@ jobs:
|
|||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
docker-build-args: '-f src/summary/Dockerfile --target summary-production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
@@ -199,8 +161,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
file: ./src/summary/Dockerfile
|
file: ./src/summary/Dockerfile
|
||||||
target: production
|
target: summary-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 }}
|
||||||
@@ -208,18 +169,10 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-agents:
|
build-and-push-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
-
|
|
||||||
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
|
||||||
@@ -238,7 +191,7 @@ jobs:
|
|||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
docker-build-args: '-f src/agents/Dockerfile --target agents-production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
docker-context: './src/agents'
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
@@ -247,16 +200,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
file: ./src/agents/Dockerfile
|
file: ./src/agents/Dockerfile
|
||||||
target: production
|
target: agents-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 }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
notify-argocd:
|
notify-argocd:
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
needs:
|
needs:
|
||||||
- build-and-push-frontend-generic
|
- build-and-push-frontend-generic
|
||||||
- build-and-push-frontend-dinum
|
- build-and-push-frontend-dinum
|
||||||
|
|||||||
@@ -7,18 +7,14 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
@@ -43,11 +39,9 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||||
github.event_name == 'pull_request'
|
github.event_name == 'pull_request'
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- name: Check that the CHANGELOG has been modified in the current branch
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
@@ -55,11 +49,9 @@ jobs:
|
|||||||
|
|
||||||
lint-changelog:
|
lint-changelog:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- name: Check CHANGELOG max line length
|
- name: Check CHANGELOG max line length
|
||||||
run: |
|
run: |
|
||||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
@@ -70,22 +62,20 @@ jobs:
|
|||||||
|
|
||||||
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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -105,49 +95,43 @@ 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@v5
|
uses: actions/cache@v4
|
||||||
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@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
cache: "pip"
|
||||||
uses: astral-sh/setup-uv@v7
|
- name: Install development dependencies
|
||||||
- name: Install the project
|
run: pip install --user .[dev]
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
|
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: uv run ruff format . --diff
|
run: ~/.local/bin/ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: uv run ruff check .
|
run: ~/.local/bin/ruff check .
|
||||||
- name: Lint code with pylint
|
- name: Lint code with pylint
|
||||||
run: uv run pylint meet demo core
|
run: ~/.local/bin/pylint meet demo core
|
||||||
|
|
||||||
lint-agents:
|
lint-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
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@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -160,16 +144,14 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -183,8 +165,7 @@ 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
|
||||||
@@ -223,6 +204,8 @@ jobs:
|
|||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
REDIS_URL: redis://localhost:6379/1
|
REDIS_URL: redis://localhost:6379/1
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
|
LIVEKIT_API_SECRET: secret
|
||||||
|
LIVEKIT_API_KEY: devkey
|
||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
AWS_S3_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
|
||||||
@@ -230,11 +213,10 @@ jobs:
|
|||||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||||
OIDC_OP_URL: https://oidc.example.com
|
OIDC_OP_URL: https://oidc.example.com
|
||||||
MEDIA_BASE_URL: http://localhost:8083
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -242,7 +224,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@v5
|
uses: actions/cache@v4
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -276,54 +258,7 @@ jobs:
|
|||||||
mc mb meet/meet-media-storage"
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
- name: Install the dependencies
|
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
|
|
||||||
- name: Install gettext (required to compile messages)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y gettext
|
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
|
||||||
run: uv run python manage.py compilemessages
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: uv run pytest -n 2
|
|
||||||
|
|
||||||
test-summary:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: src/summary
|
|
||||||
|
|
||||||
env:
|
|
||||||
V1_TENANT_ID: 'test-tenant'
|
|
||||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
|
||||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
|
||||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
|
||||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
|
||||||
WHISPERX_ASR_MODEL: "large-v2"
|
|
||||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
|
||||||
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
|
||||||
LLM_BASE_URL: "https://configure-your-url.com"
|
|
||||||
LLM_API_KEY: "test-llm-secret"
|
|
||||||
LLM_MODEL: "test-llm-model"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -331,16 +266,22 @@ jobs:
|
|||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
- name: Run summary tests
|
- name: Install gettext (required to compile messages)
|
||||||
run: ~/.local/bin/pytest
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
|
- name: Generate a MO file from strings extracted from the project
|
||||||
|
run: python manage.py compilemessages
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: ~/.local/bin/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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci
|
run: cd src/frontend/ && npm ci
|
||||||
@@ -353,14 +294,12 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -373,15 +312,13 @@ 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@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- 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@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# /!\
|
|
||||||
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
|
||||||
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
|
||||||
# to ensure workflows only run after a maintainer has reviewed the PR.
|
|
||||||
name: Security Review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write # Needed for leaving PR comments
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
|
||||||
with:
|
|
||||||
comment-pr: true
|
|
||||||
exclude-directories: docs,gitlint,LICENSES,bin
|
|
||||||
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
|
||||||
@@ -31,7 +31,6 @@ MANIFEST
|
|||||||
|
|
||||||
# Translations # Translations
|
# Translations # Translations
|
||||||
*.pot
|
*.pot
|
||||||
*.mo
|
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -8,194 +8,6 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [1.15.0] - 2026-04-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(backend) add metadata collection of VAD, connection and chat events
|
|
||||||
- ✨(backend) introduce add-ons authentication backend
|
|
||||||
- 💬(backend) clarify french transcription audio download link text #1299
|
|
||||||
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
|
|
||||||
- 🔧(backend) add setting to toggle application token exchange mechanism
|
|
||||||
- ✨(backend) support add-ons authentication in external viewset
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(summary) support webm #1290
|
|
||||||
- ⬆️(backend) bump django-lasuite to v0.0.26
|
|
||||||
- 🩹(frontend) use a more standard (quality) rating scale
|
|
||||||
- 🩹(frontend) fix access control for screen recording feature flag
|
|
||||||
- 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
|
|
||||||
|
|
||||||
## [1.14.0] - 2026-04-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 🔒️(helm) Add pod and container securityContext #1197
|
|
||||||
- ✨(summary) add routes v2 for async STT and summary tasks #1171
|
|
||||||
- ✅(backend) add unit tests for JwtTokenService #1232
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
|
|
||||||
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
|
|
||||||
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
|
|
||||||
- ♻️(backend) use Authorization header for LiveKit token authentication
|
|
||||||
- 🥅(backend) refine Twirp error handling for participant operations
|
|
||||||
- ✨(summary) allow more file extensions #1265
|
|
||||||
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
|
|
||||||
- ♿️(frontend) set an explicit document title on recording download page #1261
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
|
|
||||||
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
|
|
||||||
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
|
|
||||||
- 🔒(backend) rely on backend to allow participant update their metadata
|
|
||||||
- 🐛(summary) fix failure webhook notification #1233
|
|
||||||
- 🐛(summary) relax whisperX payload format #1233
|
|
||||||
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
|
|
||||||
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
|
|
||||||
|
|
||||||
## [1.13.0] - 2026-03-31
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ⬆️(dependencies) update python dependencies
|
|
||||||
- ♿️(frontend) add explicit region for call controls #1216
|
|
||||||
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
|
|
||||||
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
|
|
||||||
- 🐛(backend) fix regression in update-participant endpoint #1204
|
|
||||||
|
|
||||||
## [1.12.0] - 2026-03-24
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
|
|
||||||
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
|
||||||
- ♿️(frontend) fix more tools heading hierarchy #1181
|
|
||||||
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
|
||||||
- 💄(spinner) enforce spinner height #1183
|
|
||||||
- 💄(custom-background) add upload indicator with preview #1183
|
|
||||||
- ♿️(backend) improve logo accessibility in recording email notification #1092
|
|
||||||
- ♿️(summary) improve accessibility of transcription download link #1187
|
|
||||||
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
|
|
||||||
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
|
|
||||||
- ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
|
|
||||||
- ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
|
|
||||||
- ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) disable personal custom background while deleting #1183
|
|
||||||
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
|
||||||
- 🐛(frontend) fix device selection not applying during conference #1156
|
|
||||||
|
|
||||||
## [1.11.0] - 2026-03-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(helm) support celery with our Django backend #1124
|
|
||||||
- ✨(helm) support ingress for custom background image #1124
|
|
||||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
|
||||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
|
||||||
- ✨(file-upload) disable by default & limit count by user #1141
|
|
||||||
- ✨(frontend) custom background #1067
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
|
||||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
|
||||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
|
||||||
- ♿️(frontend) improve chat toast a11y for screen readers #1109
|
|
||||||
- ♿️(frontend) improve ui and aria labels for help article links #1108
|
|
||||||
- 🌐(frontend) improve German translation #1125
|
|
||||||
- 🔨(python-env) migrate meet main app to UV #1120
|
|
||||||
- ♻️(backend) align Application model field with `is_active` convention #1133
|
|
||||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
|
||||||
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
|
||||||
- 🔒️(backend) avoid information exposure through exception messages #1144
|
|
||||||
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
|
|
||||||
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
|
||||||
- ♿️(frontend) add caption font and background color customization #1122
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
|
|
||||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
|
||||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
|
||||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
|
||||||
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
|
|
||||||
|
|
||||||
## [1.10.0] - 2026-03-05
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
|
||||||
- 🦺(backend) strengthen API validation for recording options #1063
|
|
||||||
- ⚡️(frontend) optimize few performance caveats #1073
|
|
||||||
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
|
||||||
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(migrations) use settings in migrations #1058
|
|
||||||
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
|
||||||
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
|
||||||
- ♿(frontend) dynamic tab title when connected to meeting #1060
|
|
||||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
|
||||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
|
||||||
- ♿(frontend) announce selected state to screen readers #1081
|
|
||||||
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(backend) add file upload feature #1030
|
|
||||||
|
|
||||||
## [1.9.0] - 2026-03-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 👷(docker) add arm64 platform support for image builds
|
|
||||||
- ✨(summary) add localization support for transcription context text
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
|
|
||||||
- 🔒️(frontend) uninstall curl from the frontend production image #987
|
|
||||||
- 💄(frontend) add focus ring to reaction emoji buttons
|
|
||||||
- ✨(frontend) introduce a shortcut settings tab #975
|
|
||||||
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
|
||||||
- 🌐(frontend) localize SR modifier labels #1010
|
|
||||||
- ⬆️(backend) update python dependencies #1011
|
|
||||||
- ♿️(frontend) fix focus ring on tab container components #1012
|
|
||||||
- ♿️(frontend) upgrade join meeting modal accessibility #1027
|
|
||||||
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
|
||||||
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
|
||||||
- ♿️(frontend) add skip link component for keyboard navigation #1019
|
|
||||||
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🩹(frontend) fix German language preference update #1021
|
|
||||||
|
|
||||||
## [1.8.0] - 2026-02-20
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- 🔒️(agents) uninstall pip from the agents image
|
|
||||||
- 🔒️(summary) switch to Alpine base image
|
|
||||||
- 🔒️(backend) uninstall pip in the production image
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
|
|
||||||
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
|
|
||||||
|
|
||||||
## [1.7.0] - 2026-02-19
|
## [1.7.0] - 2026-02-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -206,7 +18,6 @@ and this project adheres to
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
- ✨(frontend) add clickable settings general link in idle modal #974
|
||||||
- ♻️(backend) refactor external API token-related items #1006
|
|
||||||
|
|
||||||
## [1.6.0] - 2026-02-10
|
## [1.6.0] - 2026-02-10
|
||||||
|
|
||||||
|
|||||||
@@ -13,28 +13,14 @@ RUN apk update && \
|
|||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base AS back-builder
|
||||||
|
|
||||||
|
WORKDIR /builder
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1
|
# Copy required python dependencies
|
||||||
ENV UV_LINK_MODE=copy
|
COPY ./src/backend /builder
|
||||||
|
|
||||||
# Disable Python downloads, because we want to use the system interpreter
|
RUN mkdir /install && \
|
||||||
# across both images. If using a managed Python version, it needs to be
|
pip install --prefix=/install .
|
||||||
# copied from the build image into the final image;
|
|
||||||
ENV UV_PYTHON_DOWNLOADS=0
|
|
||||||
|
|
||||||
# install uv
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
||||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
|
||||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
|
||||||
uv sync --locked --no-install-project --no-dev
|
|
||||||
COPY src/backend /app
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
||||||
uv sync --locked --no-dev
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:20 AS mail-builder
|
||||||
@@ -44,7 +30,7 @@ COPY ./src/mail /mail/app
|
|||||||
WORKDIR /mail/app
|
WORKDIR /mail/app
|
||||||
|
|
||||||
RUN yarn install --frozen-lockfile && \
|
RUN yarn install --frozen-lockfile && \
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
@@ -53,20 +39,19 @@ ARG MEET_STATIC_ROOT=/data/static
|
|||||||
|
|
||||||
RUN apk add \
|
RUN apk add \
|
||||||
pango \
|
pango \
|
||||||
libmagic \
|
|
||||||
rdfind
|
rdfind
|
||||||
|
|
||||||
|
# Copy installed python dependencies
|
||||||
|
COPY --from=back-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy Meet application (see .dockerignore)
|
||||||
|
COPY ./src/backend /app/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the application from the builder
|
|
||||||
COPY --from=back-builder /app /app
|
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
|
|
||||||
|
|
||||||
# collectstatic
|
# collectstatic
|
||||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||||
# final image
|
# final image
|
||||||
@@ -83,7 +68,6 @@ RUN apk --no-cache add \
|
|||||||
gettext \
|
gettext \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
pango \
|
pango \
|
||||||
libmagic \
|
|
||||||
shared-mime-info
|
shared-mime-info
|
||||||
|
|
||||||
|
|
||||||
@@ -95,17 +79,14 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
|||||||
# docker user (see entrypoint).
|
# docker user (see entrypoint).
|
||||||
RUN chmod g=u /etc/passwd
|
RUN chmod g=u /etc/passwd
|
||||||
|
|
||||||
# Copy the application from the builder
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /app /app
|
COPY --from=back-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy Meet application (see .dockerignore)
|
||||||
|
COPY ./src/backend /app/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
|
|
||||||
# Generate compiled translation messages
|
|
||||||
RUN DJANGO_CONFIGURATION=Build \
|
|
||||||
python manage.py compilemessages --ignore=".venv/**/*"
|
|
||||||
|
|
||||||
# We wrap commands run in this container by the following entrypoint that
|
# We wrap commands run in this container by the following entrypoint that
|
||||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||||
# ID.
|
# ID.
|
||||||
@@ -120,9 +101,10 @@ USER root:root
|
|||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apk add postgresql-client
|
||||||
|
|
||||||
# Install development dependencies
|
# Uninstall Meet and re-install it in editable mode along with development
|
||||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
|
# dependencies
|
||||||
uv sync --all-extras --locked
|
RUN pip uninstall -y meet
|
||||||
|
RUN pip install -e .[dev]
|
||||||
|
|
||||||
# Restore the un-privileged user running the application
|
# Restore the un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
@@ -131,7 +113,7 @@ USER ${DOCKER_USER}
|
|||||||
# Target database host (e.g. database engine following docker compose services
|
# Target database host (e.g. database engine following docker compose services
|
||||||
# name) & port
|
# name) & port
|
||||||
ENV DB_HOST=postgresql \
|
ENV DB_HOST=postgresql \
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Run django development server
|
# Run django development server
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
@@ -145,9 +127,6 @@ 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,10 +23,9 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# VARIABLES
|
# VARIABLES
|
||||||
|
|
||||||
ESC := $(shell printf '\033')
|
BOLD := \033[1m
|
||||||
BOLD := $(ESC)[1m
|
RESET := \033[0m
|
||||||
RESET := $(ESC)[0m
|
GREEN := \033[1;32m
|
||||||
GREEN := $(ESC)[1;32m
|
|
||||||
|
|
||||||
|
|
||||||
# -- Database
|
# -- Database
|
||||||
@@ -41,9 +40,9 @@ DOCKER_GID = $(shell id -g)
|
|||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-development
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-development
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||||
|
|
||||||
@@ -73,9 +72,7 @@ create-env-files: \
|
|||||||
env.d/development/crowdin \
|
env.d/development/crowdin \
|
||||||
env.d/development/postgresql \
|
env.d/development/postgresql \
|
||||||
env.d/development/kc_postgresql \
|
env.d/development/kc_postgresql \
|
||||||
env.d/development/summary \
|
env.d/development/summary
|
||||||
env.d/development/kube-secret \
|
|
||||||
env.d/development/multi_user_transcriber
|
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare Docker images for the project
|
||||||
@@ -88,19 +85,17 @@ 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
|
||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend
|
@$(MAKE) build-backend
|
||||||
@$(MAKE) build-frontend
|
@$(MAKE) build-frontend
|
||||||
@$(MAKE) build-agents
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
build-backend: ## build the app-dev container
|
build-backend: ## build the app-development container
|
||||||
@$(COMPOSE) build app-dev
|
@$(COMPOSE) build app-development
|
||||||
.PHONY: build-backend
|
.PHONY: build-backend
|
||||||
|
|
||||||
|
|
||||||
@@ -108,21 +103,16 @@ build-frontend: ## build the frontend container
|
|||||||
@$(COMPOSE) build frontend
|
@$(COMPOSE) build frontend
|
||||||
.PHONY: build-frontend
|
.PHONY: build-frontend
|
||||||
|
|
||||||
build-agents: ## build the multi-user-transcriber agent container
|
|
||||||
@$(COMPOSE) build multi-user-transcriber
|
|
||||||
.PHONY: build-agents
|
|
||||||
|
|
||||||
down: ## stop and remove containers, networks, images, and volumes
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE) down
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
|
|
||||||
logs: ## display app-dev logs (follow mode)
|
logs: ## display app-development logs (follow mode)
|
||||||
@$(COMPOSE) logs -f app-dev
|
@$(COMPOSE) logs -f app-development
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
|
|
||||||
run-backend: ## start only the backend application and all needed services
|
run-backend: ## start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
@$(COMPOSE) up --force-recreate -d celery-development
|
||||||
@$(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
|
||||||
@@ -132,24 +122,10 @@ run-summary: ## start only the summary application and all needed services
|
|||||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||||
.PHONY: run-summary
|
.PHONY: run-summary
|
||||||
|
|
||||||
run-agents: ## start the multi-user-transcriber agent
|
|
||||||
@$(MAKE) run-agent-multi-user-transcriber
|
|
||||||
@$(MAKE) run-agent-metadata-collector
|
|
||||||
.PHONY: run-agents
|
|
||||||
|
|
||||||
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
|
|
||||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
|
||||||
.PHONY: run-agent-multi-user-transcriber
|
|
||||||
|
|
||||||
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
|
|
||||||
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
|
|
||||||
.PHONY: run-agent-metadata-collector
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(MAKE) run-summary
|
@$(MAKE) run-summary
|
||||||
@$(MAKE) run-agents
|
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
@@ -212,7 +188,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
|||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests
|
||||||
@$(MAKE) test-back-parallel
|
@$(MAKE) test-back-parallel
|
||||||
@$(MAKE) test-summary
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests
|
test-back: ## run back-end tests
|
||||||
@@ -225,11 +200,6 @@ test-back-parallel: ## run all back-end tests in parallel
|
|||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args:-${1}}
|
||||||
.PHONY: test-back-parallel
|
.PHONY: test-back-parallel
|
||||||
|
|
||||||
test-summary: ## run summary tests
|
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
|
||||||
bin/pytest-summary $${args:-${1}}
|
|
||||||
.PHONY: test-summary
|
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
@@ -250,21 +220,21 @@ 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
|
||||||
@$(MANAGE) makemessages -a --keep-pot
|
@$(MANAGE) makemessages -a --keep-pot
|
||||||
.PHONY: back-i18n-generate
|
.PHONY: back-i18n-generate
|
||||||
|
|
||||||
shell: ## connect to database shell
|
shell: ## open a Django Python shell
|
||||||
@$(MANAGE) shell #_plus
|
@$(MANAGE) shell #_plus
|
||||||
.PHONY: dbshell
|
.PHONY: shell
|
||||||
|
|
||||||
# -- Database
|
# -- Database
|
||||||
|
|
||||||
dbshell: ## connect to database shell
|
dbshell: ## connect to database shell
|
||||||
docker compose exec app-dev python manage.py dbshell
|
docker compose exec app-development python manage.py dbshell
|
||||||
.PHONY: dbshell
|
.PHONY: dbshell
|
||||||
|
|
||||||
resetdb: FLUSH_ARGS ?=
|
resetdb: FLUSH_ARGS ?=
|
||||||
@@ -286,12 +256,6 @@ env.d/development/kc_postgresql:
|
|||||||
env.d/development/summary:
|
env.d/development/summary:
|
||||||
cp -n env.d/development/summary.dist env.d/development/summary
|
cp -n env.d/development/summary.dist env.d/development/summary
|
||||||
|
|
||||||
env.d/development/kube-secret:
|
|
||||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
|
||||||
|
|
||||||
env.d/development/multi_user_transcriber:
|
|
||||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
|
||||||
|
|
||||||
# -- Internationalization
|
# -- Internationalization
|
||||||
|
|
||||||
env.d/development/crowdin:
|
env.d/development/crowdin:
|
||||||
@@ -379,15 +343,21 @@ frontend-i18n-generate: \
|
|||||||
|
|
||||||
# -- K8S
|
# -- K8S
|
||||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||||
build-k8s-cluster: \
|
./bin/start-kind.sh
|
||||||
env.d/development/kube-secret \
|
|
||||||
./bin/start-kind.sh
|
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||||
|
./bin/install-external-secrets.sh
|
||||||
|
.PHONY: install-external-secrets
|
||||||
|
|
||||||
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
|
tilt up -f ./bin/Tiltfile
|
||||||
|
.PHONY: start-tilt
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: start-tilt-keycloak
|
||||||
|
|
||||||
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
||||||
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: start-tilt-dinum
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
|||||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||||
namespace_create('meet')
|
namespace_create('meet')
|
||||||
|
|
||||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
||||||
|
|
||||||
if DEV_ENV == 'dev-dinum':
|
if DEV_ENV == 'dev-dinum':
|
||||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||||
@@ -34,11 +34,10 @@ docker_build(
|
|||||||
'localhost:5001/meet-frontend-dinum:latest',
|
'localhost:5001/meet-frontend-dinum:latest',
|
||||||
context='..',
|
context='..',
|
||||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||||
only=['./src/frontend', './src/addons', './docker', './.dockerignore'],
|
only=['./src/frontend', './docker', './.dockerignore'],
|
||||||
target = 'frontend-production',
|
target = 'frontend-production',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../src/frontend', '/home/frontend'),
|
sync('../src/frontend', '/home/frontend'),
|
||||||
sync('../src/addons', '/home/addons'),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
clean_old_images('localhost:5001/meet-frontend-dinum')
|
clean_old_images('localhost:5001/meet-frontend-dinum')
|
||||||
@@ -60,7 +59,7 @@ docker_build(
|
|||||||
context='../src/summary',
|
context='../src/summary',
|
||||||
dockerfile='../src/summary/Dockerfile',
|
dockerfile='../src/summary/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'summary-production',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../src/summary', '/app'),
|
sync('../src/summary', '/app'),
|
||||||
]
|
]
|
||||||
@@ -71,8 +70,8 @@ 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 = 'agents-production',
|
||||||
live_update=[
|
live_update=[
|
||||||
sync('../src/agents', '/app'),
|
sync('../src/agents', '/app'),
|
||||||
]
|
]
|
||||||
@@ -96,22 +95,11 @@ docker_build(
|
|||||||
)
|
)
|
||||||
clean_old_images('localhost:5001/meet-livekit')
|
clean_old_images('localhost:5001/meet-livekit')
|
||||||
|
|
||||||
load('ext://secret', 'secret_yaml_generic')
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||||
k8s_yaml(secret_yaml_generic(
|
|
||||||
name="secret-dev",
|
|
||||||
from_env_file="../env.d/development/kube-secret"
|
|
||||||
))
|
|
||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
|
||||||
|
|
||||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
k8s_resource('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-summary-backend', 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'])
|
||||||
|
|||||||
@@ -89,5 +89,5 @@ function _dc_exec() {
|
|||||||
#
|
#
|
||||||
# ARGS : django's manage.py command arguments
|
# ARGS : django's manage.py command arguments
|
||||||
function _django_manage() {
|
function _django_manage() {
|
||||||
_dc_run "app-dev" python manage.py "$@"
|
_dc_run "app-development" python manage.py "$@"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|||||||
|
|
||||||
_dc_run \
|
_dc_run \
|
||||||
-e DJANGO_CONFIGURATION=Test \
|
-e DJANGO_CONFIGURATION=Test \
|
||||||
app-dev \
|
app-development \
|
||||||
pytest "$@"
|
pytest "$@"
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|
||||||
|
|
||||||
_dc_run \
|
|
||||||
app-summary-dev \
|
|
||||||
python -m pytest "$@"
|
|
||||||
@@ -4,7 +4,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|||||||
|
|
||||||
_dc_run \
|
_dc_run \
|
||||||
-e DJANGO_CONFIGURATION=Test \
|
-e DJANGO_CONFIGURATION=Test \
|
||||||
app-dev \
|
app-development \
|
||||||
python manage.py spectacular \
|
python manage.py spectacular \
|
||||||
--api-version 'v1.0' \
|
--api-version 'v1.0' \
|
||||||
--urlconf 'meet.api_urls' \
|
--urlconf 'meet.api_urls' \
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ services:
|
|||||||
entrypoint: >
|
entrypoint: >
|
||||||
sh -c "
|
sh -c "
|
||||||
/usr/bin/mc alias set meet http://minio:9000 meet password &&
|
/usr/bin/mc alias set meet http://minio:9000 meet password &&
|
||||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-development:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||||
/usr/bin/mc admin service restart meet --wait --json &&
|
/usr/bin/mc admin service restart meet --wait --json &&
|
||||||
sleep 15 &&
|
sleep 15 &&
|
||||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
|
||||||
exit 0;"
|
exit 0;"
|
||||||
|
|
||||||
app-dev:
|
app-development:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: backend-development
|
target: backend-development
|
||||||
@@ -80,11 +80,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
- /app/.venv
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
- postgresql
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
|
- nginx
|
||||||
- livekit
|
- livekit
|
||||||
- createbuckets
|
- createbuckets
|
||||||
- createwebhook
|
- createwebhook
|
||||||
@@ -94,7 +94,7 @@ services:
|
|||||||
- resource-server
|
- resource-server
|
||||||
- default
|
- default
|
||||||
|
|
||||||
celery-dev:
|
celery-development:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-development
|
image: meet:backend-development
|
||||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
|
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
|
||||||
@@ -106,11 +106,10 @@ 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-development
|
||||||
|
|
||||||
app:
|
app-production:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
@@ -129,7 +128,7 @@ services:
|
|||||||
- livekit
|
- livekit
|
||||||
- minio
|
- minio
|
||||||
|
|
||||||
celery:
|
celery-production:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-production
|
image: meet:backend-production
|
||||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
|
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
|
||||||
@@ -139,7 +138,7 @@ services:
|
|||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app-production
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.25
|
image: nginx:1.25
|
||||||
@@ -149,7 +148,6 @@ services:
|
|||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- keycloak
|
- keycloak
|
||||||
- app-dev
|
|
||||||
networks:
|
networks:
|
||||||
- resource-server
|
- resource-server
|
||||||
- default
|
- default
|
||||||
@@ -163,7 +161,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
VITE_API_BASE_URL: "http://localhost:8071"
|
VITE_API_BASE_URL: "http://localhost:8071"
|
||||||
VITE_APP_TITLE: "LaSuite Meet"
|
VITE_APP_TITLE: "LaSuite Meet"
|
||||||
image: meet:frontend-development
|
image: meet:frontend-production
|
||||||
ports:
|
ports:
|
||||||
- "3000:8080"
|
- "3000:8080"
|
||||||
|
|
||||||
@@ -246,38 +244,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
metadata-collector-dev:
|
|
||||||
build:
|
|
||||||
context: ./src/agents
|
|
||||||
command: ["python", "metadata_collector.py", "dev"]
|
|
||||||
environment:
|
|
||||||
- LIVEKIT_URL=ws://livekit:7880
|
|
||||||
- LIVEKIT_API_KEY=devkey
|
|
||||||
- LIVEKIT_API_SECRET=secret
|
|
||||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
|
||||||
- AWS_S3_ACCESS_KEY_ID=meet
|
|
||||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
|
||||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
|
||||||
- AWS_S3_SECURE_ACCESS=False
|
|
||||||
volumes:
|
|
||||||
- ./src/agents:/app
|
|
||||||
depends_on:
|
|
||||||
- livekit
|
|
||||||
- minio
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: rebuild
|
|
||||||
path: ./src/agents
|
|
||||||
|
|
||||||
redis-summary:
|
redis-summary:
|
||||||
image: redis
|
image: redis
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
app-summary-dev:
|
app-summary-development:
|
||||||
build:
|
build:
|
||||||
context: src/summary
|
context: src/summary
|
||||||
target: development
|
target: summary-development
|
||||||
args:
|
args:
|
||||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
@@ -295,7 +270,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: summary-production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
@@ -303,7 +278,7 @@ services:
|
|||||||
- ./src/summary:/app
|
- ./src/summary:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-summary
|
- redis-summary
|
||||||
- app-summary-dev
|
- app-summary-development
|
||||||
- minio
|
- minio
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
@@ -315,7 +290,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: summary-production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
@@ -323,21 +298,13 @@ services:
|
|||||||
- ./src/summary:/app
|
- ./src/summary:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-summary
|
- redis-summary
|
||||||
- app-summary-dev
|
- app-summary-development
|
||||||
- minio
|
- minio
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: ./src/summary
|
path: ./src/summary
|
||||||
|
|
||||||
multi-user-transcriber:
|
|
||||||
build:
|
|
||||||
context: ./src/agents
|
|
||||||
env_file:
|
|
||||||
- env.d/development/multi_user_transcriber
|
|
||||||
volumes:
|
|
||||||
- ./src/agents:/app
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
resource-server:
|
resource-server:
|
||||||
|
|||||||
@@ -38,32 +38,16 @@ COPY ./docker/dinum-frontend/assets/ \
|
|||||||
COPY ./docker/dinum-frontend/fonts/ \
|
COPY ./docker/dinum-frontend/fonts/ \
|
||||||
./dist/assets/fonts/
|
./dist/assets/fonts/
|
||||||
|
|
||||||
# ---- Addons builder image ----
|
|
||||||
FROM node:20-alpine AS addons-builder
|
|
||||||
|
|
||||||
WORKDIR /home/addons/outlook
|
|
||||||
|
|
||||||
COPY ./src/addons/outlook/package.json ./package.json
|
|
||||||
COPY ./src/addons/outlook/package-lock.json ./package-lock.json
|
|
||||||
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY ./src/addons/outlook/ .
|
|
||||||
|
|
||||||
RUN npx webpack --mode production
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
RUN apk update && apk upgrade libssl3 \
|
||||||
# Security patches for known CVEs
|
libcrypto3 \
|
||||||
RUN apk update && apk upgrade \
|
libxml2>=2.12.7-r2 \
|
||||||
musl \
|
libxslt>=1.1.39-r2 \
|
||||||
musl-utils \
|
libexpat>=2.7.2-r0 \
|
||||||
zlib>=1.3.2-r0 \
|
libpng>=1.6.53-r0
|
||||||
&& apk del curl
|
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
@@ -75,11 +59,7 @@ COPY --from=meet-builder \
|
|||||||
/home/frontend/dist \
|
/home/frontend/dist \
|
||||||
/usr/share/nginx/html
|
/usr/share/nginx/html
|
||||||
|
|
||||||
COPY --from=addons-builder \
|
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||||
/home/addons/outlook/dist \
|
|
||||||
/usr/share/nginx/html/addons/outlook
|
|
||||||
|
|
||||||
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
|
|
||||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name localhost;
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
|
|
||||||
location = /.well-known/windows-app-web-link {
|
|
||||||
default_type application/json;
|
|
||||||
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
|
|
||||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Manifest — fetched, never iframed
|
|
||||||
location = /addons/outlook/manifest.xml {
|
|
||||||
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
|
|
||||||
|
|
||||||
add_header Access-Control-Allow-Origin "*";
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
add_header X-Frame-Options "DENY";
|
|
||||||
add_header Content-Security-Policy "frame-ancestors 'none'";
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /addons/outlook/assets/ {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, max-age=2592000, immutable" always;
|
|
||||||
add_header Access-Control-Allow-Origin "*";
|
|
||||||
add_header Vary "Origin" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /addons/outlook/ {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/addons/outlook(/.*)?$ {
|
|
||||||
alias /usr/share/nginx/html/addons/outlook$1;
|
|
||||||
error_page 404 =200 /index.html;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
add_header Pragma "no-cache" always;
|
|
||||||
add_header Expires 0 always;
|
|
||||||
|
|
||||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
|
||||||
|
|
||||||
set $nonce $request_id;
|
|
||||||
|
|
||||||
set $csp "upgrade-insecure-requests; ";
|
|
||||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
|
||||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
|
||||||
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
|
|
||||||
set $csp "${csp}frame-src 'none'; ";
|
|
||||||
set $csp "${csp}object-src 'none'; ";
|
|
||||||
set $csp "${csp}base-uri 'none'; ";
|
|
||||||
|
|
||||||
add_header Content-Security-Policy $csp;
|
|
||||||
|
|
||||||
sub_filter 'NONCE_PLACEHOLDER' $nonce;
|
|
||||||
sub_filter_once off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files with caching
|
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, max-age=2592000";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
# Add no-cache headers
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
|
|
||||||
add_header Expires 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optionally, handle 404 errors by redirecting to index.html
|
|
||||||
error_page 404 =200 /index.html;
|
|
||||||
}
|
|
||||||
@@ -4,47 +4,10 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
# Proxy auth for media
|
|
||||||
location /media/ {
|
|
||||||
# Auth request configuration
|
|
||||||
auth_request /media-auth;
|
|
||||||
auth_request_set $authHeader $upstream_http_authorization;
|
|
||||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
|
||||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
|
||||||
|
|
||||||
# Pass specific headers from the auth response
|
|
||||||
proxy_set_header Authorization $authHeader;
|
|
||||||
proxy_set_header X-Amz-Date $authDate;
|
|
||||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
|
||||||
|
|
||||||
# Get resource from Minio
|
|
||||||
proxy_pass http://minio:9000/meet-media-storage/;
|
|
||||||
proxy_set_header Host minio:9000;
|
|
||||||
# To use with ds_proxy
|
|
||||||
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
|
||||||
# proxy_set_header Host ds-proxy:4444;
|
|
||||||
add_header Content-Disposition "attachment";
|
|
||||||
}
|
|
||||||
|
|
||||||
location /media-auth {
|
|
||||||
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Original-URL $request_uri;
|
|
||||||
|
|
||||||
# Prevent the body from being passed
|
|
||||||
proxy_pass_request_body off;
|
|
||||||
proxy_set_header Content-Length "";
|
|
||||||
proxy_set_header X-Original-Method $request_method;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://keycloak:8080;
|
proxy_pass http://keycloak:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM livekit/livekit-server:v1.9.4
|
FROM livekit/livekit-server:v1.9.0
|
||||||
|
|
||||||
# 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/
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ keys:
|
|||||||
webhook:
|
webhook:
|
||||||
api_key: devkey
|
api_key: devkey
|
||||||
urls:
|
urls:
|
||||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
- http://app-development:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|||||||
@@ -61,10 +61,11 @@ services:
|
|||||||
`docker compose up -d`
|
`docker compose up -d`
|
||||||
```
|
```
|
||||||
|
|
||||||
Your keycloak instance is now available on https://id.yourdomain.tld
|
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||||
|
```
|
||||||
|
|
||||||
## Creating an OIDC Client for Meet Application
|
## Creating an OIDC Client for Meet Application
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
|
|||||||
3. Enter the name of the realm - `meet`.
|
3. Enter the name of the realm - `meet`.
|
||||||
4. Click "Create".
|
4. Click "Create".
|
||||||
|
|
||||||
### Step 2: Create a New Client
|
#### Step 2: Create a New Client
|
||||||
|
|
||||||
1. Navigate to the "Clients" tab.
|
1. Navigate to the "Clients" tab.
|
||||||
2. Click on the "Create client" button.
|
2. Click on the "Create client" button.
|
||||||
@@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
|
|||||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||||
1. Click "Save".
|
1. Click "Save".
|
||||||
|
|
||||||
### Step 3: Get Client Credentials
|
#### Step 3: Get Client Credentials
|
||||||
|
|
||||||
1. Go to the "Credentials" tab.
|
1. Go to the "Credentials" tab.
|
||||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ backend:
|
|||||||
python manage.py createsuperuser --email admin@example.com --password admin
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumeMounts:
|
extraVolumeMounts:
|
||||||
- name: certs
|
- name: certs
|
||||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||||
subPath: cacert.pem
|
subPath: cacert.pem
|
||||||
|
|
||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumes:
|
extraVolumes:
|
||||||
- name: certs
|
- name: certs
|
||||||
configMap:
|
configMap:
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
|
|||||||
|
|
||||||
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
||||||
|
|
||||||
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
||||||
|
|
||||||
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
|
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports.
|
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
|
||||||
|
|
||||||
### Meet
|
### Meet
|
||||||
|
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
|
|||||||
$ kubectl create namespace meet
|
$ kubectl create namespace meet
|
||||||
```
|
```
|
||||||
|
|
||||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed.
|
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ kubectl config set-context --current --namespace=meet
|
$ kubectl config set-context --current --namespace=meet
|
||||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml
|
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||||
$ #wait until
|
$ #wait until
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
|
|||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can find these values in **examples/helm/keycloak.values.yaml**
|
You can find these values in **examples/keycloak.values.yaml**
|
||||||
|
|
||||||
### Find livekit server connexion values
|
### Find livekit server connexion values
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
|
|||||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml
|
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 26m
|
keycloak-0 1/1 Running 0 26m
|
||||||
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
|
|||||||
```
|
```
|
||||||
$ helm repo add livekit https://helm.livekit.io
|
$ helm repo add livekit https://helm.livekit.io
|
||||||
$ helm repo update
|
$ helm repo update
|
||||||
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
|
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 30m
|
keycloak-0 1/1 Running 0 30m
|
||||||
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
|||||||
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml
|
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 45m
|
keycloak-0 1/1 Running 0 45m
|
||||||
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
|
|||||||
```
|
```
|
||||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||||
$ helm repo update
|
$ helm repo update
|
||||||
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
|
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test your deployment
|
## Test your deployment
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Set the following environment variables in your Scalingo app:
|
|||||||
### Buildpack Configuration
|
### Buildpack Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
|
scalingo env-set BUILDBACK_URL="https://github.com/suitenumerique/buildpack#main"
|
||||||
scalingo env-set LASUITE_APP_NAME="meet"
|
scalingo env-set LASUITE_APP_NAME="meet"
|
||||||
scalingo env-set LASUITE_BACKEND_DIR="."
|
scalingo env-set LASUITE_BACKEND_DIR="."
|
||||||
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/ForbiddenError'
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
/rooms/:
|
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Rooms
|
- Rooms
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/ForbiddenError'
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
/rooms/:
|
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Rooms
|
- Rooms
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ MEET_BASE_URL="http://localhost:8072"
|
|||||||
|
|
||||||
# Media
|
# Media
|
||||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
|
||||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||||
AWS_S3_ACCESS_KEY_ID=meet
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
MEDIA_BASE_URL=http://localhost:3000
|
|
||||||
FILE_UPLOAD_ENABLED=True
|
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||||
@@ -64,16 +61,13 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
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-development:8000/api/v1/tasks/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|
||||||
# Metadata
|
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
|
||||||
|
|
||||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
WHISPERX_BASE_URL=https://configure-your-url.com
|
|
||||||
WHISPERX_API_KEY=<key>
|
|
||||||
LLM_BASE_URL=https://configure-your-url.com
|
|
||||||
LLM_API_KEY=<key>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
LIVEKIT_URL=ws://livekit:7880
|
|
||||||
LIVEKIT_API_KEY=devkey
|
|
||||||
LIVEKIT_API_SECRET=secret
|
|
||||||
|
|
||||||
STT_PROVIDER=kyutai
|
|
||||||
ENABLE_SILERO_VAD=False
|
|
||||||
|
|
||||||
KYUTAI_STT_BASE_URL=
|
|
||||||
KYUTAI_API_KEY=
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
APP_NAME="meet-app-summary-dev"
|
APP_NAME="meet-app-summary-development"
|
||||||
APP_API_TOKEN="password"
|
APP_API_TOKEN="password"
|
||||||
|
|
||||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
Gitlint extra rule to validate that the message title is of the form
|
Gitlint extra rule to validate that the message title is of the form
|
||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -3,21 +3,6 @@
|
|||||||
"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",
|
||||||
@@ -30,18 +15,6 @@
|
|||||||
"matchPackageNames": ["pylint"],
|
"matchPackageNames": ["pylint"],
|
||||||
"allowedVersions": "<4.0.0"
|
"allowedVersions": "<4.0.0"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"groupName": "allowed django versions",
|
|
||||||
"matchManagers": ["pep621"],
|
|
||||||
"matchPackageNames": ["django"],
|
|
||||||
"allowedVersions": "<6.0.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"groupName": "allowed brevo versions",
|
|
||||||
"matchManagers": ["pep621"],
|
|
||||||
"matchPackageNames": ["brevo-python"],
|
|
||||||
"allowedVersions": "<3.0.0"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"plugins": [
|
|
||||||
"office-addins"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"plugin:office-addins/recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 678 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 927 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 756 B |
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"esmodules": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
|
||||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
|
||||||
<Version>0.0.1.0</Version>
|
|
||||||
<ProviderName>__APP_NAME__</ProviderName>
|
|
||||||
<DefaultLocale>fr-FR</DefaultLocale>
|
|
||||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
|
||||||
<Description DefaultValue="Ajoutez facilement un lien de réunion __APP_NAME__ à vos emails et événements Outlook."/>
|
|
||||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
|
||||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
|
|
||||||
<SupportUrl DefaultValue="https://lasuite.crisp.help/fr/category/visio-15sakkg/"/>
|
|
||||||
<AppDomains>
|
|
||||||
<AppDomain>https://localhost:3000/</AppDomain>
|
|
||||||
</AppDomains>
|
|
||||||
<Hosts>
|
|
||||||
<Host Name="Mailbox"/>
|
|
||||||
</Hosts>
|
|
||||||
<Requirements>
|
|
||||||
<Sets>
|
|
||||||
<Set Name="Mailbox" MinVersion="1.1"/>
|
|
||||||
</Sets>
|
|
||||||
</Requirements>
|
|
||||||
<FormSettings>
|
|
||||||
<Form xsi:type="ItemRead">
|
|
||||||
<DesktopSettings>
|
|
||||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
|
||||||
<RequestedHeight>250</RequestedHeight>
|
|
||||||
</DesktopSettings>
|
|
||||||
</Form>
|
|
||||||
<Form xsi:type="ItemEdit">
|
|
||||||
<DesktopSettings>
|
|
||||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
|
||||||
</DesktopSettings>
|
|
||||||
</Form>
|
|
||||||
</FormSettings>
|
|
||||||
<Permissions>ReadWriteItem</Permissions>
|
|
||||||
<Rule xsi:type="RuleCollection" Mode="Or">
|
|
||||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
|
||||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
|
|
||||||
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
|
|
||||||
</Rule>
|
|
||||||
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
|
||||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
|
|
||||||
<Requirements>
|
|
||||||
<bt:Sets DefaultMinVersion="1.3">
|
|
||||||
<bt:Set Name="Mailbox"/>
|
|
||||||
</bt:Sets>
|
|
||||||
</Requirements>
|
|
||||||
<Hosts>
|
|
||||||
<Host xsi:type="MailHost">
|
|
||||||
<DesktopFormFactor>
|
|
||||||
<FunctionFile resid="Commands.Url"/>
|
|
||||||
|
|
||||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
|
||||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
|
||||||
<OfficeTab id="TabDefault">
|
|
||||||
<Group id="msgReadGroup">
|
|
||||||
<Label resid="GroupLabel"/>
|
|
||||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
|
||||||
<Label resid="TaskpaneButton.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="TaskpaneButton.Label"/>
|
|
||||||
<Description resid="TaskpaneButton.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Icon.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Icon.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Icon.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ShowTaskpane">
|
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
</Group>
|
|
||||||
</OfficeTab>
|
|
||||||
</ExtensionPoint>
|
|
||||||
|
|
||||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
|
||||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
|
||||||
<OfficeTab id="TabDefault">
|
|
||||||
<Group id="msgComposeGroup">
|
|
||||||
<Label resid="GroupLabel"/>
|
|
||||||
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
|
||||||
<Label resid="GenerateLink.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="GenerateLink.Label"/>
|
|
||||||
<Description resid="GenerateLink.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Add.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Add.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Add.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ExecuteFunction">
|
|
||||||
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
|
||||||
<Label resid="TaskpaneButton.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="TaskpaneButton.Label"/>
|
|
||||||
<Description resid="TaskpaneButton.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Settings.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Settings.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Settings.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ShowTaskpane">
|
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
</Group>
|
|
||||||
</OfficeTab>
|
|
||||||
</ExtensionPoint>
|
|
||||||
|
|
||||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
|
||||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
|
||||||
<OfficeTab id="TabDefault">
|
|
||||||
<Group id="apptComposeGroup">
|
|
||||||
<Label resid="GroupLabel"/>
|
|
||||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
|
||||||
<Label resid="GenerateLink.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="GenerateLink.Label"/>
|
|
||||||
<Description resid="GenerateLink.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Add.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Add.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Add.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ExecuteFunction">
|
|
||||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
|
|
||||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
|
||||||
<Label resid="OpenSettings.Label"/>
|
|
||||||
<Supertip>
|
|
||||||
<Title resid="OpenSettings.Label"/>
|
|
||||||
<Description resid="OpenSettings.Tooltip"/>
|
|
||||||
</Supertip>
|
|
||||||
<Icon>
|
|
||||||
<bt:Image size="16" resid="Settings.16x16"/>
|
|
||||||
<bt:Image size="32" resid="Settings.32x32"/>
|
|
||||||
<bt:Image size="80" resid="Settings.80x80"/>
|
|
||||||
</Icon>
|
|
||||||
<Action xsi:type="ShowTaskpane">
|
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
|
||||||
</Action>
|
|
||||||
</Control>
|
|
||||||
|
|
||||||
</Group>
|
|
||||||
</OfficeTab>
|
|
||||||
</ExtensionPoint>
|
|
||||||
|
|
||||||
</DesktopFormFactor>
|
|
||||||
</Host>
|
|
||||||
</Hosts>
|
|
||||||
<Resources>
|
|
||||||
<bt:Images>
|
|
||||||
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/assets/settings-16.png"/>
|
|
||||||
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/assets/settings-32.png"/>
|
|
||||||
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/assets/settings-80.png"/>
|
|
||||||
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/assets/add-16.png"/>
|
|
||||||
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/assets/add-32.png"/>
|
|
||||||
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/assets/add-80.png"/>
|
|
||||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
|
|
||||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
|
||||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
|
|
||||||
</bt:Images>
|
|
||||||
<bt:Urls>
|
|
||||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
|
|
||||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
|
||||||
</bt:Urls>
|
|
||||||
<bt:ShortStrings>
|
|
||||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
|
||||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
|
|
||||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
|
|
||||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
|
||||||
</bt:ShortStrings>
|
|
||||||
<bt:LongStrings>
|
|
||||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
|
||||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
|
|
||||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
|
||||||
</bt:LongStrings>
|
|
||||||
</Resources>
|
|
||||||
</VersionOverrides>
|
|
||||||
</OfficeApp>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "office-addin-taskpane-js",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/suitenumerique/meet.git"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"config": {
|
|
||||||
"app_to_debug": "outlook",
|
|
||||||
"app_type_to_debug": "desktop",
|
|
||||||
"dev_server_port": 3000
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "webpack --mode production",
|
|
||||||
"build:dev": "webpack --mode development",
|
|
||||||
"dev-server": "webpack serve --mode development",
|
|
||||||
"lint": "office-addin-lint check",
|
|
||||||
"lint:fix": "office-addin-lint fix",
|
|
||||||
"prettier": "office-addin-lint prettier",
|
|
||||||
"signin": "office-addin-dev-settings m365-account login",
|
|
||||||
"signout": "office-addin-dev-settings m365-account logout",
|
|
||||||
"start": "office-addin-debugging start manifest.xml",
|
|
||||||
"stop": "office-addin-debugging stop manifest.xml",
|
|
||||||
"validate": "office-addin-manifest validate manifest.xml",
|
|
||||||
"watch": "webpack --mode development --watch"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"core-js": "^3.36.0",
|
|
||||||
"regenerator-runtime": "^0.14.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.24.0",
|
|
||||||
"@babel/preset-env": "^7.25.4",
|
|
||||||
"@types/office-js": "^1.0.377",
|
|
||||||
"@types/office-runtime": "^1.0.35",
|
|
||||||
"acorn": "^8.11.3",
|
|
||||||
"babel-loader": "^9.1.3",
|
|
||||||
"copy-webpack-plugin": "^12.0.2",
|
|
||||||
"eslint-plugin-office-addins": "^4.0.3",
|
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"html-loader": "^5.0.0",
|
|
||||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
|
||||||
"html-webpack-plugin": "^5.6.0",
|
|
||||||
"office-addin-cli": "^2.0.3",
|
|
||||||
"office-addin-debugging": "^6.0.3",
|
|
||||||
"office-addin-dev-certs": "^2.0.3",
|
|
||||||
"office-addin-lint": "^3.0.3",
|
|
||||||
"office-addin-manifest": "^2.0.3",
|
|
||||||
"office-addin-prettier-config": "^2.0.1",
|
|
||||||
"os-browserify": "^0.3.0",
|
|
||||||
"process": "^0.11.10",
|
|
||||||
"source-map-loader": "^5.0.0",
|
|
||||||
"webpack": "^5.95.0",
|
|
||||||
"webpack-cli": "^5.1.4",
|
|
||||||
"webpack-dev-server": "5.1.0"
|
|
||||||
},
|
|
||||||
"prettier": "office-addin-prettier-config",
|
|
||||||
"browserslist": [
|
|
||||||
"last 2 versions",
|
|
||||||
"ie 11"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title data-app-name></title>
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
</html>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
/* global Office */
|
|
||||||
const { createRoom, initSession } = require("../common/api");
|
|
||||||
const { startPolling } = require("../common/polling");
|
|
||||||
const { saveSession, loadSession } = require("../common/session");
|
|
||||||
const { openTransitDialog } = require("../common/transitDialog");
|
|
||||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
|
||||||
const { applyAppName } = require("../common/helpers");
|
|
||||||
|
|
||||||
Office.onReady(function (info) {
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
|
||||||
applyAppName();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function notify(message) {
|
|
||||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
|
||||||
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
|
||||||
message,
|
|
||||||
persistent: false,
|
|
||||||
icon: "Icon.16x16",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertMeetingLink(event, session) {
|
|
||||||
createRoom(session)
|
|
||||||
.then((data) => {
|
|
||||||
const { url, message } = buildMeetingMessage(data);
|
|
||||||
const item = Office.context.mailbox.item;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
|
||||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
notify(`Erreur de lecture : ${getResult.error.message}`);
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBody = getResult.value + message;
|
|
||||||
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
|
||||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
notify(`Erreur d'insertion : ${setResult.error.message}`);
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
|
||||||
notify("Lien de réunion inséré !");
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.location.setAsync(url, (locationResult) => {
|
|
||||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
notify(`Erreur de localisation : ${locationResult.error.message}`);
|
|
||||||
} else {
|
|
||||||
notify("Lien de réunion inséré !");
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
notify(`Erreur : ${err.message}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
event.completed();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect(event) {
|
|
||||||
initSession()
|
|
||||||
.then((data) => {
|
|
||||||
const stopPolling = startPolling(data.csrf_token, {
|
|
||||||
onSuccess: (sessionData) => {
|
|
||||||
saveSession(sessionData).then(() => {
|
|
||||||
insertMeetingLink(event, sessionData);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onTimeout: () => {
|
|
||||||
notify("Connexion expirée, veuillez réessayer.");
|
|
||||||
event.completed();
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
notify("Une erreur est survenue, veuillez ré-essayer");
|
|
||||||
event.completed();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
openTransitDialog(data.transit_token, {
|
|
||||||
onCancel: () => {
|
|
||||||
stopPolling();
|
|
||||||
event.completed();
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
stopPolling();
|
|
||||||
event.completed();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
notify(`Erreur : ${err.message}`);
|
|
||||||
event.completed();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMeetingLink(event) {
|
|
||||||
const session = loadSession();
|
|
||||||
if (session?.access_token) {
|
|
||||||
insertMeetingLink(event, session);
|
|
||||||
} else {
|
|
||||||
connect(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLink);
|
|
||||||
Office.actions.associate("generateMeetingLinkFromMail", generateMeetingLink);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
const { URLS } = require("./urls");
|
|
||||||
|
|
||||||
function getCsrfToken() {
|
|
||||||
return document.cookie
|
|
||||||
.split(";")
|
|
||||||
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
|
|
||||||
.map((cookie) => cookie.split("=")[1])
|
|
||||||
.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
function authHeaders(session) {
|
|
||||||
return {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds headers for CSRF-protected requests.
|
|
||||||
*
|
|
||||||
* Two CSRF flows coexist in this addon:
|
|
||||||
*
|
|
||||||
* 1. Cookie-based (Django default): used by `exchange`, called from the
|
|
||||||
* OAuth success page in a normal browser context. Django's CSRF
|
|
||||||
* middleware has already set the `csrftoken` cookie via the auth
|
|
||||||
* redirect, so we read it from `document.cookie` and echo it back
|
|
||||||
* as `X-CSRFToken`. The middleware verifies the header matches the
|
|
||||||
* cookie. No `csrfToken` argument needed — `getCsrfToken()` handles it.
|
|
||||||
*
|
|
||||||
* 2. Body-passed token: used by `poll`, called from the Office dialog /
|
|
||||||
* taskpane iframe. Cookie access inside Office iframes is unreliable
|
|
||||||
* across Outlook clients, so we can't depend on `document.cookie`
|
|
||||||
* being populated. Instead, `init` returns the CSRF token in its JSON
|
|
||||||
* response body, and callers pass it explicitly to subsequent calls.
|
|
||||||
* The token still travels as `X-CSRFToken` — only its source differs.
|
|
||||||
*
|
|
||||||
* The `csrfToken` parameter takes precedence when provided; falls back
|
|
||||||
* to the cookie when omitted.
|
|
||||||
*/
|
|
||||||
function csrfHeaders(csrfToken) {
|
|
||||||
const token = csrfToken || getCsrfToken();
|
|
||||||
return {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token && { "X-CSRFToken": token }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(path, { session, csrf, csrfToken, ...opts } = {}) {
|
|
||||||
const headers = {
|
|
||||||
...(session && authHeaders(session)),
|
|
||||||
...(csrf && csrfHeaders(csrfToken)),
|
|
||||||
...opts.headers,
|
|
||||||
};
|
|
||||||
const res = await fetch(path, {
|
|
||||||
...opts,
|
|
||||||
headers,
|
|
||||||
credentials: csrf ? "include" : opts.credentials,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
initSession: () => request(URLS.init, { method: "POST" }),
|
|
||||||
pollSession: (csrfToken) =>
|
|
||||||
request(URLS.poll, {
|
|
||||||
method: "POST",
|
|
||||||
csrf: true,
|
|
||||||
csrfToken,
|
|
||||||
}),
|
|
||||||
exchangeSession: (transitToken) =>
|
|
||||||
request(URLS.exchange, {
|
|
||||||
method: "POST",
|
|
||||||
csrf: true,
|
|
||||||
body: JSON.stringify({ transit_token: transitToken }),
|
|
||||||
}),
|
|
||||||
createRoom: (session) =>
|
|
||||||
request(URLS.rooms, {
|
|
||||||
method: "POST",
|
|
||||||
session,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
const { APP_NAME } = require("./index");
|
|
||||||
|
|
||||||
function isOfficeReady() {
|
|
||||||
return typeof Office !== "undefined" && Office?.context?.roamingSettings != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAppName() {
|
|
||||||
document.querySelectorAll("[data-app-name]").forEach((el) => {
|
|
||||||
el.textContent = APP_NAME;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
isOfficeReady,
|
|
||||||
applyAppName,
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
|
|
||||||
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
BASE_URL,
|
|
||||||
APP_NAME,
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
const { APP_NAME } = require("./index");
|
|
||||||
|
|
||||||
function _formatPin(pin) {
|
|
||||||
if (!pin) return "";
|
|
||||||
const clean = String(pin).replace(/\s+/g, "");
|
|
||||||
if (!clean) return "";
|
|
||||||
if (/^\d{10}$/.test(clean)) {
|
|
||||||
return clean.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#";
|
|
||||||
}
|
|
||||||
return clean + "#";
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo - support international format
|
|
||||||
function _formatPhone(phone) {
|
|
||||||
if (!phone) return "";
|
|
||||||
const clean = String(phone).replace(/\s+/g, "");
|
|
||||||
if (/^\+33\d{9}$/.test(clean)) {
|
|
||||||
return clean.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5");
|
|
||||||
}
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo - escape html / link
|
|
||||||
function buildMeetingMessage(data) {
|
|
||||||
if (!data?.url) {
|
|
||||||
throw new Error("buildMeetingMessage: missing url in data");
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = data.url;
|
|
||||||
const phone = _formatPhone(data.telephony?.phone_number);
|
|
||||||
const pin = _formatPin(data.telephony?.pin_code);
|
|
||||||
|
|
||||||
const telephonyBlock =
|
|
||||||
phone && pin
|
|
||||||
? `
|
|
||||||
|
|
||||||
Ou appelez (audio uniquement)
|
|
||||||
(FR) ${phone}
|
|
||||||
Code : ${pin}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
|
||||||
────────────────────────────────────────
|
|
||||||
Rejoindre la réunion ${APP_NAME}
|
|
||||||
|
|
||||||
<a href="${url}">${url}</a>${telephonyBlock}
|
|
||||||
────────────────────────────────────────</pre>`;
|
|
||||||
|
|
||||||
return { url, message };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { buildMeetingMessage };
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
const { pollSession } = require("./api");
|
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 1000;
|
|
||||||
const POLLING_TIMEOUT_MS = 3 * 60 * 1000;
|
|
||||||
const POLLING_MAX_ATTEMPTS = POLLING_TIMEOUT_MS / POLLING_INTERVAL_MS;
|
|
||||||
|
|
||||||
function isPollAuthenticated(sessionData) {
|
|
||||||
return sessionData.state === "authenticated" && sessionData.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPolling(csrfToken, { onSuccess, onTimeout, onError }) {
|
|
||||||
let pollCount = 0;
|
|
||||||
let timeoutId = null;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const poll = () => {
|
|
||||||
if (pollCount++ >= POLLING_MAX_ATTEMPTS) {
|
|
||||||
onTimeout?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pollSession(csrfToken)
|
|
||||||
.then((sessionData) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (isPollAuthenticated(sessionData)) {
|
|
||||||
onSuccess?.(sessionData);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timeoutId = setTimeout(poll, POLLING_INTERVAL_MS);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
onError?.(err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
startPolling,
|
|
||||||
};
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
const { isOfficeReady } = require("./helpers");
|
|
||||||
|
|
||||||
const SESSION_KEY = "meetSession";
|
|
||||||
|
|
||||||
// DEV NOTE:
|
|
||||||
// Office.context.roamingSettings persists data in the user's mailbox and
|
|
||||||
// synchronizes it via Exchange across all Outlook clients (desktop, web, mobile)
|
|
||||||
// where the user signs in. This means anything stored here (including tokens)
|
|
||||||
// leaves the local device boundary and is replicated across environments.
|
|
||||||
//
|
|
||||||
// Microsoft guidance explicitly advises NOT storing secrets (e.g., OAuth access
|
|
||||||
// tokens, refresh tokens, or other sensitive credentials) in roamingSettings,
|
|
||||||
// as it is not a secure storage mechanism and lacks OS-level protections.
|
|
||||||
//
|
|
||||||
// That said, for the current alpha version we accept this trade-off for simplicity,
|
|
||||||
// with the expectation that a more secure approach (e.g., in-memory tokens) will replace this.
|
|
||||||
function saveSession(data) {
|
|
||||||
if (!isOfficeReady()) {
|
|
||||||
return Promise.reject(new Error("Office not ready"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || !data.access_token) {
|
|
||||||
return Promise.reject(new Error("Missing access_token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresInSeconds = Number(data.expires_in);
|
|
||||||
const expiresAt =
|
|
||||||
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
|
||||||
? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
...data,
|
|
||||||
expiresAt,
|
|
||||||
savedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const rs = Office.context.roamingSettings;
|
|
||||||
rs.set(SESSION_KEY, payload);
|
|
||||||
rs.saveAsync((result) => {
|
|
||||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(result.error?.message || "saveAsync failed"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSession() {
|
|
||||||
if (!isOfficeReady()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let session = null;
|
|
||||||
try {
|
|
||||||
const stored = Office.context.roamingSettings.get(SESSION_KEY);
|
|
||||||
if (stored) session = JSON.parse(stored);
|
|
||||||
} catch (e) {
|
|
||||||
clearSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
// Fail closed if expiry is missing — backend is expected to send expires_in.
|
|
||||||
if (!session.expiresAt) {
|
|
||||||
clearSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresTs = Date.parse(session.expiresAt);
|
|
||||||
if (!Number.isFinite(expiresTs) || Date.now() >= expiresTs) {
|
|
||||||
clearSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSession() {
|
|
||||||
if (!isOfficeReady()) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
const rs = Office.context.roamingSettings;
|
|
||||||
rs.remove(SESSION_KEY);
|
|
||||||
rs.saveAsync((result) => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
saveSession,
|
|
||||||
loadSession,
|
|
||||||
clearSession,
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
const { URLS } = require("./urls");
|
|
||||||
|
|
||||||
const DIALOG_SIGNALS = {
|
|
||||||
ready: "ready",
|
|
||||||
done: "done",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DIALOG_HEIGHT = 60;
|
|
||||||
const DIALOG_WIDTH = 50;
|
|
||||||
|
|
||||||
function openTransitDialog(transitToken, { onCancel, onError }) {
|
|
||||||
Office.context.ui.displayDialogAsync(
|
|
||||||
URLS.transitDialog,
|
|
||||||
{ height: DIALOG_HEIGHT, width: DIALOG_WIDTH, displayInIframe: false },
|
|
||||||
(asyncResult) => {
|
|
||||||
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
|
|
||||||
onError?.(asyncResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialog = asyncResult.value;
|
|
||||||
|
|
||||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg) => {
|
|
||||||
if (arg.message === DIALOG_SIGNALS.ready) {
|
|
||||||
dialog.messageChild(transitToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (arg.message === DIALOG_SIGNALS.done) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onCancel?.();
|
|
||||||
dialog.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
openTransitDialog,
|
|
||||||
DIALOG_SIGNALS,
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
const TRANSIT_TOKEN_KEY = "transitToken";
|
|
||||||
|
|
||||||
function save(token) {
|
|
||||||
sessionStorage.setItem(TRANSIT_TOKEN_KEY, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
function consume() {
|
|
||||||
try {
|
|
||||||
const token = sessionStorage.getItem(TRANSIT_TOKEN_KEY);
|
|
||||||
sessionStorage.removeItem(TRANSIT_TOKEN_KEY);
|
|
||||||
return token;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to read transit token:", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { save, consume };
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
const { BASE_URL } = require("./index");
|
|
||||||
|
|
||||||
const ADDONS_BASE_URL = `${BASE_URL}/api/v1.0/addons/sessions`;
|
|
||||||
|
|
||||||
const URLS = {
|
|
||||||
authenticate: `${BASE_URL}/api/v1.0/authenticate/`,
|
|
||||||
successPage: `${BASE_URL}/addons/outlook/success.html`,
|
|
||||||
transitDialog: `${BASE_URL}/addons/outlook/transit.html`,
|
|
||||||
init: `${ADDONS_BASE_URL}/init/`,
|
|
||||||
poll: `${ADDONS_BASE_URL}/poll/`,
|
|
||||||
exchange: `${ADDONS_BASE_URL}/exchange/`,
|
|
||||||
rooms: `${BASE_URL}/external-api/v1.0/rooms/`,
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = { URLS };
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sideload-msg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-container {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-svg {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background arc (light gray ring) */
|
|
||||||
.spinner-track {
|
|
||||||
stroke: #E5E7EB; /* primary.100 equivalent */
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 3;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Foreground rotating arc */
|
|
||||||
.spinner-arc {
|
|
||||||
stroke: #000091; /* primary.800 equivalent */
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 3;
|
|
||||||
stroke-linecap: round;
|
|
||||||
/* circumference = 2 * PI * r where r = 11 -> ~69.115 */
|
|
||||||
/* show 30% -> dashoffset = c - 0.3 * c = ~48.38 */
|
|
||||||
stroke-dasharray: 69.115 69.115;
|
|
||||||
stroke-dashoffset: 48.38;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: spinner-rotate 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner-rotate {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hourglass fallback for reduced motion */
|
|
||||||
.spinner-fallback {
|
|
||||||
display: none;
|
|
||||||
color: #000091;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.spinner-svg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.spinner-fallback {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title data-app-name></title>
|
|
||||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
|
||||||
<div class="spinner-container"
|
|
||||||
role="progressbar"
|
|
||||||
aria-label="Chargement..."
|
|
||||||
>
|
|
||||||
<svg class="spinner-svg"
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<!-- Background track -->
|
|
||||||
<circle class="spinner-track" cx="14" cy="14" r="11"
|
|
||||||
/>
|
|
||||||
<!-- Rotating arc -->
|
|
||||||
<circle class="spinner-arc" cx="14" cy="14" r="11"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
|
|
||||||
<span class="spinner-fallback" aria-hidden="true">
|
|
||||||
<svg width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
style="display: block; transform: translateY(1px);"
|
|
||||||
>
|
|
||||||
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
const { applyAppName } = require("../common/helpers");
|
|
||||||
const { exchangeSession } = require("../common/api");
|
|
||||||
const { consume } = require("../common/transitToken");
|
|
||||||
|
|
||||||
applyAppName();
|
|
||||||
|
|
||||||
const transitToken = consume();
|
|
||||||
|
|
||||||
if (!transitToken) {
|
|
||||||
console.error("Transit token not found in sessionStorage");
|
|
||||||
window.close();
|
|
||||||
} else {
|
|
||||||
exchangeSession(transitToken)
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(`Error occured: ${e}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
window.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title data-app-name></title>
|
|
||||||
<link rel="stylesheet" href="taskpane.css" />
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
|
||||||
|
|
||||||
<div id="app-body">
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div id="view-loading">
|
|
||||||
<p class="intro-text">Chargement...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unauthenticated -->
|
|
||||||
<div id="view-unauth" style="display:none;">
|
|
||||||
<p class="intro-text">
|
|
||||||
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
|
|
||||||
</p>
|
|
||||||
<hr class="divider" />
|
|
||||||
<button class="proconnect-button" id="btn-connect">
|
|
||||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://www.proconnect.gouv.fr/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
|
||||||
>
|
|
||||||
Qu’est-ce que ProConnect ?
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Authenticated -->
|
|
||||||
<div id="view-auth" style="display:none;">
|
|
||||||
<div id="btn-container">
|
|
||||||
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
|
|
||||||
<button id="btn-disconnect">Se déconnecter</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer id="version-tag">
|
|
||||||
<span class="version-badge">alpha</span>
|
|
||||||
<span class="version-number">0.0.1</span>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
const { APP_NAME } = require("../common");
|
|
||||||
|
|
||||||
const { applyAppName } = require("../common/helpers");
|
|
||||||
const { initSession, createRoom } = require("../common/api");
|
|
||||||
const { startPolling } = require("../common/polling");
|
|
||||||
const { openTransitDialog } = require("../common/transitDialog");
|
|
||||||
const { loadSession, saveSession, clearSession } = require("../common/session");
|
|
||||||
|
|
||||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
|
||||||
|
|
||||||
// todo - support loading view while polling
|
|
||||||
// todo - support error view
|
|
||||||
function showView(name) {
|
|
||||||
document.getElementById("view-loading").style.display = "none";
|
|
||||||
document.getElementById("view-unauth").style.display = "none";
|
|
||||||
document.getElementById("view-auth").style.display = "none";
|
|
||||||
document.getElementById(`view-${name}`).style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
initSession()
|
|
||||||
.then((data) => {
|
|
||||||
const stopPolling = startPolling(data.csrf_token, {
|
|
||||||
onSuccess: (sessionData) => {
|
|
||||||
saveSession(sessionData).then(() => showView("auth"));
|
|
||||||
},
|
|
||||||
onTimeout: () => {
|
|
||||||
showView("unauth");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
openTransitDialog(data.transit_token, {
|
|
||||||
onCancel: () => stopPolling(),
|
|
||||||
onError: (err) => {
|
|
||||||
stopPolling();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
clearSession().finally(() => showView("unauth"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setButtonLoading() {
|
|
||||||
const btn = document.getElementById("btn-generate");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = "Génération...";
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setButtonIdle() {
|
|
||||||
const btn = document.getElementById("btn-generate");
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMeetingLink() {
|
|
||||||
const session = loadSession();
|
|
||||||
if (!session?.access_token) {
|
|
||||||
console.error("Session introuvable. Veuillez vous reconnecter.");
|
|
||||||
showView("unauth");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setButtonLoading();
|
|
||||||
|
|
||||||
createRoom(session)
|
|
||||||
.then((data) => {
|
|
||||||
const { url, message } = buildMeetingMessage(data);
|
|
||||||
const item = Office.context.mailbox.item;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
|
||||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
reject(getResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.body.setAsync(
|
|
||||||
getResult.value + message,
|
|
||||||
{ coercionType: Office.CoercionType.Html },
|
|
||||||
(setResult) => {
|
|
||||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
reject(setResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── If calendar event, also set location ──────────────
|
|
||||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
|
||||||
item.location.setAsync(url, () => resolve());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
_setButtonIdle();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Office.onReady((info) => {
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
|
||||||
applyAppName();
|
|
||||||
document.getElementById("sideload-msg").style.display = "none";
|
|
||||||
document.getElementById("app-body").style.display = "flex";
|
|
||||||
document.getElementById("btn-connect").onclick = connect;
|
|
||||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
|
||||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
|
||||||
|
|
||||||
const session = loadSession();
|
|
||||||
if (session?.state === "authenticated" && session?.access_token) {
|
|
||||||
showView("auth");
|
|
||||||
} else {
|
|
||||||
showView("unauth");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title data-app-name></title>
|
|
||||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
|
||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
|
||||||
<div
|
|
||||||
class="spinner-container"
|
|
||||||
role="progressbar"
|
|
||||||
aria-label="Chargement..."
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="spinner-svg"
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<!-- Background track -->
|
|
||||||
<circle class="spinner-track" cx="14" cy="14" r="11"
|
|
||||||
/>
|
|
||||||
<!-- Rotating arc -->
|
|
||||||
<circle class="spinner-arc" cx="14" cy="14" r="11"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
|
|
||||||
<span class="spinner-fallback" aria-hidden="true">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
style="display: block; transform: translateY(1px);"
|
|
||||||
>
|
|
||||||
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
const { applyAppName } = require("../common/helpers");
|
|
||||||
const { URLS } = require("../common/urls");
|
|
||||||
const { save } = require("../common/transitToken");
|
|
||||||
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
|
||||||
|
|
||||||
// Initiate the authentication flow, then return to the success page
|
|
||||||
function getAuthenticateUrl() {
|
|
||||||
const url = new URL(URLS.authenticate);
|
|
||||||
url.searchParams.set("returnTo", URLS.successPage);
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
Office.onReady(function (info) {
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
|
||||||
applyAppName();
|
|
||||||
}
|
|
||||||
|
|
||||||
Office.context.ui.addHandlerAsync(
|
|
||||||
Office.EventType.DialogParentMessageReceived,
|
|
||||||
function (arg) {
|
|
||||||
const transitToken = arg.message;
|
|
||||||
|
|
||||||
if (typeof transitToken !== "string" || transitToken.trim() === "") {
|
|
||||||
console.error("Invalid transit token received from parent dialog.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs inside the dialog window.
|
|
||||||
// Flow:
|
|
||||||
// transit.html saves token → navigates to /authenticate → OAuth redirect →
|
|
||||||
// success.html. sessionStorage survives because it's per-window-per-origin
|
|
||||||
// and the dialog window persists across same-origin navigations.
|
|
||||||
// Fragile: if the IdP opens the redirect in a new tab/window, this breaks
|
|
||||||
// silently.
|
|
||||||
// An alternative could be to pass the token via the OAuth `state` param
|
|
||||||
// and read it back from the redirect URL.
|
|
||||||
try {
|
|
||||||
save(transitToken);
|
|
||||||
Office.context.ui.messageParent(DIALOG_SIGNALS.done);
|
|
||||||
window.location.href = getAuthenticateUrl();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to store transit token:", err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
function (result) {
|
|
||||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
console.error("Failed to register DialogParentMessageReceived handler.", result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Office.context.ui.messageParent(DIALOG_SIGNALS.ready);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/* eslint-disable no-undef */
|
|
||||||
|
|
||||||
const devCerts = require("office-addin-dev-certs");
|
|
||||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
|
||||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
|
||||||
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
|
||||||
|
|
||||||
async function getHttpsOptions() {
|
|
||||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
|
||||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = async (env, options) => {
|
|
||||||
const config = {
|
|
||||||
devtool: "source-map",
|
|
||||||
entry: {
|
|
||||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
|
||||||
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
|
||||||
commands: "./src/commands/commands.js",
|
|
||||||
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
|
|
||||||
success: ["./src/success/success.js", "./src/success/success.html"],
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
clean: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: [".html", ".js"],
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.js$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: {
|
|
||||||
loader: "babel-loader",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.html$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: {
|
|
||||||
loader: "html-loader",
|
|
||||||
options: {
|
|
||||||
sources: {
|
|
||||||
urlFilter: (attribute, value) => {
|
|
||||||
// Don't try to resolve the runtime-injected config
|
|
||||||
if (value.includes("config.js")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(png|jpg|jpeg|gif|ico)$/,
|
|
||||||
type: "asset/resource",
|
|
||||||
generator: {
|
|
||||||
filename: "assets/[name][ext][query]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "taskpane.html",
|
|
||||||
template: "./src/taskpane/taskpane.html",
|
|
||||||
chunks: ["polyfill", "taskpane"],
|
|
||||||
scriptLoading: "defer",
|
|
||||||
attributes: {
|
|
||||||
nonce: "NONCE_PLACEHOLDER",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new CopyWebpackPlugin({
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
from: "assets/*",
|
|
||||||
to: "assets/[name][ext][query]",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "commands.html",
|
|
||||||
template: "./src/commands/commands.html",
|
|
||||||
chunks: ["polyfill", "commands"],
|
|
||||||
scriptLoading: "defer",
|
|
||||||
attributes: {
|
|
||||||
nonce: "NONCE_PLACEHOLDER",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "transit.html",
|
|
||||||
template: "./src/transit/transit.html",
|
|
||||||
chunks: ["polyfill", "transit"],
|
|
||||||
scriptLoading: "defer",
|
|
||||||
attributes: {
|
|
||||||
nonce: "NONCE_PLACEHOLDER",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "success.html",
|
|
||||||
template: "./src/success/success.html",
|
|
||||||
chunks: ["polyfill", "success"],
|
|
||||||
scriptLoading: "defer",
|
|
||||||
attributes: {
|
|
||||||
nonce: "NONCE_PLACEHOLDER",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new htmlWebpackInjectAttributesPlugin(),
|
|
||||||
],
|
|
||||||
devServer: {
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
type: "https",
|
|
||||||
options:
|
|
||||||
env.WEBPACK_BUILD || options.https !== undefined
|
|
||||||
? options.https
|
|
||||||
: await getHttpsOptions(),
|
|
||||||
},
|
|
||||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "thunderbird",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "web-ext run",
|
|
||||||
"lint": "web-ext lint --source-dir=./src"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"devDependencies": {
|
|
||||||
"web-ext": "^10.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { buildMeetingMessage } from "./lib/meeting-message.js";
|
|
||||||
|
|
||||||
console.log("[meeting-link] background loaded at", new Date().toISOString());
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
console.log("[meeting-link] browser.calendar exists?", !!browser.calendar);
|
|
||||||
if (browser.calendar) {
|
|
||||||
console.log("[meeting-link] calendar namespace keys:",
|
|
||||||
Object.keys(browser.calendar));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try the most basic read call — list configured calendars.
|
|
||||||
// The exact method name varies between experiment versions; we'll
|
|
||||||
// try the common one first.
|
|
||||||
if (browser.calendar.calendars?.query) {
|
|
||||||
const calendars = await browser.calendar.calendars.query({});
|
|
||||||
console.log("[meeting-link] calendars found:", calendars.length, calendars);
|
|
||||||
} else {
|
|
||||||
console.warn("[meeting-link] calendars.query not present — namespace shape:",
|
|
||||||
browser.calendar);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[meeting-link] calendar probe failed:", err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Hardcoded for Spike 1. Spike 2 replaces this with a fetch() to your API.
|
|
||||||
const STUB_MEETING_DATA = {
|
|
||||||
url: "https://meet.example.com/m/abc-123-xyz",
|
|
||||||
telephony: {
|
|
||||||
phone_number: "+33123456789",
|
|
||||||
pin_code: "1234567890",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (msg) => {
|
|
||||||
if (msg?.type === "GET_MEETING_MESSAGE") {
|
|
||||||
try {
|
|
||||||
const built = buildMeetingMessage(STUB_MEETING_DATA);
|
|
||||||
return { ok: true, ...built };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[meeting-link] build failed", err);
|
|
||||||
return { ok: false, error: String(err.message || err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 544 B |
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"manifest_version": 3,
|
|
||||||
"name": "Meeting Link (dev)",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Spike 0 — verifying the dev loop",
|
|
||||||
"browser_specific_settings": {
|
|
||||||
"gecko": {
|
|
||||||
"id": "meeting-link-dev@yourcompany.example",
|
|
||||||
"strict_min_version": "128.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"scripts": ["background.js"],
|
|
||||||
"type": "module"
|
|
||||||
},
|
|
||||||
"compose_action": {
|
|
||||||
"default_title": "Insert meeting link",
|
|
||||||
"default_popup": "popup/popup.html",
|
|
||||||
"default_icon": "icons/icon-32.png"
|
|
||||||
},
|
|
||||||
"permissions": ["compose"],
|
|
||||||
"experiment_apis": {
|
|
||||||
"calendar_provider": {
|
|
||||||
"schema": "experiments/calendar/schema/calendar-provider.json",
|
|
||||||
"parent": {
|
|
||||||
"scopes": ["addon_parent"],
|
|
||||||
"paths": [["calendar", "provider"]],
|
|
||||||
"script": "experiments/calendar/parent/ext-calendar-provider.js",
|
|
||||||
"events": ["startup"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"calendar_calendars": {
|
|
||||||
"schema": "experiments/calendar/schema/calendar-calendars.json",
|
|
||||||
"parent": {
|
|
||||||
"scopes": ["addon_parent"],
|
|
||||||
"paths": [["calendar", "calendars"]],
|
|
||||||
"script": "experiments/calendar/parent/ext-calendar-calendars.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Meeting link</title>
|
|
||||||
<style>
|
|
||||||
body { font: 13px system-ui; padding: 12px; min-width: 240px; margin: 0; }
|
|
||||||
button { font: inherit; padding: 6px 12px; cursor: pointer; }
|
|
||||||
#status { margin-top: 8px; color: #555; min-height: 1.2em; white-space: pre-wrap; }
|
|
||||||
#status.error { color: #b00020; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<button id="insert">Insert meeting link</button>
|
|
||||||
<div id="status"></div>
|
|
||||||
<script src="popup.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
const insertBtn = document.getElementById("insert");
|
|
||||||
const statusEl = document.getElementById("status");
|
|
||||||
|
|
||||||
function setStatus(text, isError = false) {
|
|
||||||
statusEl.textContent = text;
|
|
||||||
statusEl.classList.toggle("error", isError);
|
|
||||||
}
|
|
||||||
|
|
||||||
insertBtn.addEventListener("click", async () => {
|
|
||||||
insertBtn.disabled = true;
|
|
||||||
setStatus("Generating link…");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Find the compose tab this popup belongs to.
|
|
||||||
// A compose_action popup is anchored to a compose window, so the
|
|
||||||
// "active tab in the current window" is the compose tab itself.
|
|
||||||
const [composeTab] = await browser.tabs.query({
|
|
||||||
active: true,
|
|
||||||
currentWindow: true,
|
|
||||||
});
|
|
||||||
if (!composeTab) throw new Error("No compose tab found");
|
|
||||||
|
|
||||||
// 2. Read the current compose state — we need to know if we're
|
|
||||||
// in HTML mode or plain-text mode, and we need the existing body
|
|
||||||
// so we can append rather than overwrite.
|
|
||||||
const details = await browser.compose.getComposeDetails(composeTab.id);
|
|
||||||
|
|
||||||
// 3. Ask the background to produce the meeting message.
|
|
||||||
const reply = await browser.runtime.sendMessage({
|
|
||||||
type: "GET_MEETING_MESSAGE",
|
|
||||||
});
|
|
||||||
if (!reply?.ok) throw new Error(reply?.error || "Background error");
|
|
||||||
|
|
||||||
// 4. Append to the existing body in the right format.
|
|
||||||
if (details.isPlainText) {
|
|
||||||
const newBody = (details.plainTextBody || "") + "\n\n" + reply.text;
|
|
||||||
await browser.compose.setComposeDetails(composeTab.id, {
|
|
||||||
plainTextBody: newBody,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const newBody = appendHtmlBeforeBodyEnd(details.body || "", reply.html);
|
|
||||||
await browser.compose.setComposeDetails(composeTab.id, {
|
|
||||||
body: newBody,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("Inserted ✓");
|
|
||||||
setTimeout(() => window.close(), 600);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[meeting-link] popup insert failed", err);
|
|
||||||
setStatus("Failed: " + (err.message || err), true);
|
|
||||||
insertBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append HTML right before </body>, or fall back to concatenation if no
|
|
||||||
* </body> tag is present (Thunderbird's compose body is usually a full
|
|
||||||
* HTML document, but be defensive).
|
|
||||||
*/
|
|
||||||
function appendHtmlBeforeBodyEnd(currentHtml, fragment) {
|
|
||||||
const idx = currentHtml.toLowerCase().lastIndexOf("</body>");
|
|
||||||
if (idx === -1) return currentHtml + fragment;
|
|
||||||
return currentHtml.slice(0, idx) + fragment + currentHtml.slice(idx);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// web-ext-config.cjs
|
|
||||||
const os = require("os");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
function thunderbirdBinary() {
|
|
||||||
if (process.env.WEB_EXT_FIREFOX) return process.env.WEB_EXT_FIREFOX;
|
|
||||||
switch (process.platform) {
|
|
||||||
case "darwin":
|
|
||||||
return "/Applications/Thunderbird Beta.app/Contents/MacOS/thunderbird";
|
|
||||||
case "linux":
|
|
||||||
return "/usr/bin/thunderbird"; // adjust to your install
|
|
||||||
case "win32":
|
|
||||||
return "C:\\Program Files\\Mozilla Thunderbird\\thunderbird.exe";
|
|
||||||
default:
|
|
||||||
throw new Error("Unsupported platform: " + process.platform);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
sourceDir: "./src",
|
|
||||||
artifactsDir: "./web-ext-artifacts",
|
|
||||||
run: {
|
|
||||||
firefox: thunderbirdBinary(),
|
|
||||||
firefoxProfile: path.join(os.homedir(), ".thunderbird-dev-profile"),
|
|
||||||
profileCreateIfMissing: true,
|
|
||||||
keepProfileChanges: true,
|
|
||||||
browserConsole: true,
|
|
||||||
},
|
|
||||||
ignoreFiles: ["package-lock.json", "web-ext-config.cjs", "*.md"],
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13.13-slim AS base
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
# Install system dependencies required by LiveKit
|
# Install system dependencies required by LiveKit
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
@@ -15,30 +15,16 @@ COPY pyproject.toml .
|
|||||||
RUN mkdir /install && \
|
RUN mkdir /install && \
|
||||||
pip install --prefix=/install .
|
pip install --prefix=/install .
|
||||||
|
|
||||||
FROM base AS development
|
FROM base AS agents-production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml .
|
|
||||||
RUN pip install --no-cache-dir ".[dev]"
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
CMD ["python", "metadata_collector.py", "dev"]
|
|
||||||
|
|
||||||
FROM base AS production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=builder /install /usr/local
|
|
||||||
|
|
||||||
# Remove pip to reduce attack surface in production
|
|
||||||
RUN pip uninstall -y pip
|
|
||||||
|
|
||||||
# Un-privileged user running the application
|
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
USER ${DOCKER_USER}
|
USER ${DOCKER_USER}
|
||||||
|
|
||||||
COPY ./*.py /app/
|
# Un-privileged user running the application
|
||||||
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Storage parsers specific exceptions."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingConfigError(Exception):
|
|
||||||
"""Raised when a variable is not set in configuration."""
|
|
||||||
@@ -1,382 +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,
|
|
||||||
AgentServer,
|
|
||||||
AgentSession,
|
|
||||||
AutoSubscribe,
|
|
||||||
JobContext,
|
|
||||||
JobProcess,
|
|
||||||
JobRequest,
|
|
||||||
RoomInputOptions,
|
|
||||||
RoomIO,
|
|
||||||
RoomOutputOptions,
|
|
||||||
WorkerPermissions,
|
|
||||||
cli,
|
|
||||||
utils,
|
|
||||||
)
|
|
||||||
from livekit.plugins import silero
|
|
||||||
from minio import Minio
|
|
||||||
from minio.error import S3Error
|
|
||||||
|
|
||||||
from exceptions import MissingConfigError
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
logger = logging.getLogger("metadata-collector")
|
|
||||||
|
|
||||||
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
|
||||||
|
|
||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
|
||||||
"""Preload voice activity detection model."""
|
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
|
||||||
|
|
||||||
|
|
||||||
server = AgentServer(
|
|
||||||
permissions=WorkerPermissions(
|
|
||||||
can_publish=False,
|
|
||||||
can_publish_data=False,
|
|
||||||
can_subscribe=True,
|
|
||||||
hidden=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
server.setup_fnc = prewarm
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MetadataEvent:
|
|
||||||
"""A single timestamped event recorded during a meeting."""
|
|
||||||
|
|
||||||
participant_id: str
|
|
||||||
type: str
|
|
||||||
timestamp: datetime
|
|
||||||
data: Optional[str] = None
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
"""Return a JSON-serializable dictionary representation of the event."""
|
|
||||||
data = asdict(self)
|
|
||||||
data["timestamp"] = self.timestamp.isoformat()
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class VADAgent(Agent):
|
|
||||||
"""Agent that monitors voice activity for a specific participant."""
|
|
||||||
|
|
||||||
def __init__(self, participant_identity: str, events: List):
|
|
||||||
"""Initialize with a participant identity and shared events list."""
|
|
||||||
super().__init__(
|
|
||||||
instructions="not-needed",
|
|
||||||
)
|
|
||||||
self.participant_identity = participant_identity
|
|
||||||
self.events = events
|
|
||||||
|
|
||||||
async def on_enter(self) -> None:
|
|
||||||
"""Initialize VAD monitoring for this participant."""
|
|
||||||
|
|
||||||
@self.session.on("user_state_changed")
|
|
||||||
def on_user_state(event):
|
|
||||||
timestamp = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if event.new_state == "speaking":
|
|
||||||
event = MetadataEvent(
|
|
||||||
participant_id=self.participant_identity,
|
|
||||||
type="speech_start",
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
self.events.append(event)
|
|
||||||
|
|
||||||
elif event.old_state == "speaking":
|
|
||||||
event = MetadataEvent(
|
|
||||||
participant_id=self.participant_identity,
|
|
||||||
type="speech_end",
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
self.events.append(event)
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataCollector:
|
|
||||||
"""Collect meeting events across all participants in a room.
|
|
||||||
|
|
||||||
Creates one AgentSession per participant to capture VAD events
|
|
||||||
(speech start/end), and listens for connection, disconnection,
|
|
||||||
and chat events. Persists all collected events as JSON to S3
|
|
||||||
on shutdown.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ctx: JobContext, recording_id: str):
|
|
||||||
"""Initialize metadata agent."""
|
|
||||||
self.minio_client = Minio(
|
|
||||||
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
|
|
||||||
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
|
||||||
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
|
||||||
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
|
|
||||||
self.bucket_name = bucket_name
|
|
||||||
else:
|
|
||||||
raise MissingConfigError
|
|
||||||
|
|
||||||
self.ctx = ctx
|
|
||||||
self._sessions: dict[str, AgentSession] = {}
|
|
||||||
self._tasks: set[asyncio.Task] = set()
|
|
||||||
|
|
||||||
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
|
|
||||||
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
|
|
||||||
|
|
||||||
# Storage for events
|
|
||||||
self.events = []
|
|
||||||
self.participants = {}
|
|
||||||
|
|
||||||
logger.info("MetadataCollector initialized")
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start listening for room-level events."""
|
|
||||||
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
|
|
||||||
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
|
|
||||||
|
|
||||||
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
|
|
||||||
|
|
||||||
logger.info("Started listening for participant events")
|
|
||||||
|
|
||||||
async def on_chat_message_received(
|
|
||||||
self, reader: rtc.TextStreamReader, participant_identity: str
|
|
||||||
):
|
|
||||||
"""Read a complete chat message and record it as an event."""
|
|
||||||
full_text = await reader.read_all()
|
|
||||||
logger.info("Received chat message from %s", participant_identity)
|
|
||||||
|
|
||||||
self.events.append(
|
|
||||||
MetadataEvent(
|
|
||||||
participant_id=participant_identity,
|
|
||||||
type="chat_received",
|
|
||||||
timestamp=datetime.now(timezone.utc),
|
|
||||||
data=full_text,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_chat_stream(self, reader, participant_identity):
|
|
||||||
"""Schedule async processing of an incoming chat stream."""
|
|
||||||
task = asyncio.create_task(
|
|
||||||
self.on_chat_message_received(reader, participant_identity)
|
|
||||||
)
|
|
||||||
self._tasks.add(task)
|
|
||||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
"""Serialize collected events and upload as JSON to S3."""
|
|
||||||
logger.info("Persisting metadata…")
|
|
||||||
|
|
||||||
participants = []
|
|
||||||
for k, v in self.participants.items():
|
|
||||||
participants.append({"participantId": k, "name": v})
|
|
||||||
|
|
||||||
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"events": [event.serialize() for event in sorted_events],
|
|
||||||
"participants": participants,
|
|
||||||
}
|
|
||||||
|
|
||||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
|
||||||
stream = BytesIO(data)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.minio_client.put_object(
|
|
||||||
self.bucket_name,
|
|
||||||
self.output_filename,
|
|
||||||
stream,
|
|
||||||
length=len(data),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Uploaded speaker meeting metadata",
|
|
||||||
)
|
|
||||||
except S3Error:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to upload meeting metadata",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def aclose(self):
|
|
||||||
"""Close all sessions and cleanup resources."""
|
|
||||||
logger.info("Closing all VAD monitoring sessions…")
|
|
||||||
|
|
||||||
await utils.aio.cancel_and_wait(*self._tasks)
|
|
||||||
|
|
||||||
await asyncio.gather(
|
|
||||||
*[self._close_session(session) for session in self._sessions.values()],
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
|
|
||||||
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
|
|
||||||
|
|
||||||
logger.info("All VAD sessions closed")
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
async def on_participant_entrypoint(
|
|
||||||
self, ctx: JobContext, participant: rtc.RemoteParticipant
|
|
||||||
):
|
|
||||||
"""Handle new participant by starting a VAD monitoring session."""
|
|
||||||
if participant.identity in self._sessions:
|
|
||||||
logger.debug("Session already exists for %s", participant.identity)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.events.append(
|
|
||||||
MetadataEvent(
|
|
||||||
participant_id=participant.identity,
|
|
||||||
type="participant_connected",
|
|
||||||
timestamp=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.participants[participant.identity] = participant.name
|
|
||||||
|
|
||||||
logger.info("New participant connected: %s", participant.identity)
|
|
||||||
try:
|
|
||||||
session = await self._start_session(participant)
|
|
||||||
self._sessions[participant.identity] = session
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to start session for %s", participant.identity)
|
|
||||||
|
|
||||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
|
||||||
"""Handle participant disconnection by closing VAD monitoring."""
|
|
||||||
self.events.append(
|
|
||||||
MetadataEvent(
|
|
||||||
participant_id=participant.identity,
|
|
||||||
type="participant_disconnected",
|
|
||||||
timestamp=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
session = self._sessions.pop(participant.identity, None)
|
|
||||||
if session is None:
|
|
||||||
logger.debug("No session found for %s", participant.identity)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Participant disconnected: %s", participant.identity)
|
|
||||||
task = asyncio.create_task(self._close_session(session))
|
|
||||||
self._tasks.add(task)
|
|
||||||
|
|
||||||
def on_close_done(_):
|
|
||||||
self._tasks.discard(task)
|
|
||||||
logger.info(
|
|
||||||
"VAD session closed for %s (remaining sessions: %d)",
|
|
||||||
participant.identity,
|
|
||||||
len(self._sessions),
|
|
||||||
)
|
|
||||||
|
|
||||||
task.add_done_callback(on_close_done)
|
|
||||||
|
|
||||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
|
||||||
"""Update stored participant name when it changes."""
|
|
||||||
logger.info("Participant's name changed: %s", participant.identity)
|
|
||||||
self.participants[participant.identity] = participant.name
|
|
||||||
|
|
||||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
|
||||||
"""Create and start VAD monitoring session for participant."""
|
|
||||||
if participant.identity in self._sessions:
|
|
||||||
return self._sessions[participant.identity]
|
|
||||||
|
|
||||||
# Create session with VAD only - no STT, LLM, or TTS
|
|
||||||
session = AgentSession(
|
|
||||||
vad=self.ctx.proc.userdata["vad"],
|
|
||||||
turn_detection="vad",
|
|
||||||
user_away_timeout=30.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up room IO to receive audio from this specific participant
|
|
||||||
room_io = RoomIO(
|
|
||||||
agent_session=session,
|
|
||||||
room=self.ctx.room,
|
|
||||||
participant=participant,
|
|
||||||
input_options=RoomInputOptions(
|
|
||||||
audio_enabled=True,
|
|
||||||
text_enabled=False,
|
|
||||||
),
|
|
||||||
output_options=RoomOutputOptions(
|
|
||||||
audio_enabled=False,
|
|
||||||
transcription_enabled=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await room_io.start()
|
|
||||||
await session.start(
|
|
||||||
agent=VADAgent(
|
|
||||||
participant_identity=participant.identity, events=self.events
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
async def _close_session(self, session: AgentSession) -> None:
|
|
||||||
"""Close and cleanup VAD monitoring session."""
|
|
||||||
try:
|
|
||||||
await session.drain()
|
|
||||||
await session.aclose()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error closing session")
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_job_request(job_req: JobRequest) -> None:
|
|
||||||
"""Accept or reject the job request based on agent presence in the room."""
|
|
||||||
room_name = job_req.room.name
|
|
||||||
recording_id = job_req.job.metadata
|
|
||||||
agent_identity = f"{AGENT_NAME}-{room_name}"
|
|
||||||
|
|
||||||
async with api.LiveKitAPI() as lk:
|
|
||||||
try:
|
|
||||||
resp = await lk.room.list_participants(
|
|
||||||
list=api.ListParticipantsRequest(room=room_name)
|
|
||||||
)
|
|
||||||
already_present = any(
|
|
||||||
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
|
|
||||||
and p.identity == agent_identity
|
|
||||||
for p in resp.participants
|
|
||||||
)
|
|
||||||
if already_present:
|
|
||||||
logger.info("Agent already in the room '%s' — reject", room_name)
|
|
||||||
await job_req.reject()
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Accept job for '%s' — identity=%s", room_name, agent_identity
|
|
||||||
)
|
|
||||||
await job_req.accept(identity=agent_identity, metadata=recording_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error treating the job for '%s'", room_name)
|
|
||||||
await job_req.reject()
|
|
||||||
|
|
||||||
|
|
||||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
|
||||||
async def entrypoint(ctx: JobContext):
|
|
||||||
"""Initialize and run the metadata collector."""
|
|
||||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
|
||||||
recording_id = ctx.job.metadata
|
|
||||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
|
||||||
metadata_collector.start()
|
|
||||||
|
|
||||||
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
|
|
||||||
|
|
||||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
|
||||||
|
|
||||||
async def cleanup():
|
|
||||||
logger.info("Shutting down metadata collector…")
|
|
||||||
await metadata_collector.aclose()
|
|
||||||
|
|
||||||
ctx.add_shutdown_callback(cleanup)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
cli.run_app(server)
|
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.15.0"
|
version = "1.7.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.4.5",
|
"livekit-agents==1.3.10",
|
||||||
"livekit-plugins-deepgram==1.4.5",
|
"livekit-plugins-deepgram==1.3.10",
|
||||||
"livekit-plugins-silero==1.4.5",
|
"livekit-plugins-silero==1.3.10",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.1"
|
||||||
"protobuf==6.33.5",
|
|
||||||
"minio==7.2.15"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.6",
|
"ruff==0.14.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61.0"]
|
requires = ["setuptools>=61.0"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet core add-ons module."""
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from enum import Enum
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
from core.services.jwt_token import JwtTokenService
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
_PUBLIC_SESSION_FIELDS = frozenset(
|
|
||||||
{"state", "access_token", "token_type", "expires_in", "scope"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionDataError(Exception):
|
|
||||||
"""Raised when session data is invalid or malformed."""
|
|
||||||
|
|
||||||
|
|
||||||
class CSRFTokenError(Exception):
|
|
||||||
"""Raised when CSRF token verification fails."""
|
|
||||||
|
|
||||||
|
|
||||||
class TransitTokenError(Exception):
|
|
||||||
"""Raised when a transit token is invalid or expired."""
|
|
||||||
|
|
||||||
|
|
||||||
class SessionExpiredError(Exception):
|
|
||||||
"""Raised when a session has expired."""
|
|
||||||
|
|
||||||
|
|
||||||
class SessionNotFoundError(Exception):
|
|
||||||
"""Raised when a session is not found."""
|
|
||||||
|
|
||||||
|
|
||||||
class SuspiciousSessionError(Exception):
|
|
||||||
"""Raised when session state indicates a possible attack or bug."""
|
|
||||||
|
|
||||||
|
|
||||||
class SessionState(str, Enum):
|
|
||||||
"""Add-on authentication session lifecycle states."""
|
|
||||||
|
|
||||||
PENDING = "pending"
|
|
||||||
AUTHENTICATED = "authenticated"
|
|
||||||
|
|
||||||
|
|
||||||
class TransitTokenState(str, Enum):
|
|
||||||
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
|
|
||||||
|
|
||||||
PENDING = "pending"
|
|
||||||
CONSUMED = "consumed"
|
|
||||||
|
|
||||||
|
|
||||||
class TokenExchangeService:
|
|
||||||
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Build the underlying JWT service and validate required settings."""
|
|
||||||
|
|
||||||
if not settings.ADDONS_CSRF_SECRET:
|
|
||||||
raise ImproperlyConfigured("CSRF Secret is required.")
|
|
||||||
|
|
||||||
if not settings.ADDONS_TOKEN_SCOPE:
|
|
||||||
raise ImproperlyConfigured("Token scope must be defined.")
|
|
||||||
|
|
||||||
self._token_service = JwtTokenService(
|
|
||||||
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
|
||||||
issuer=settings.ADDONS_TOKEN_ISSUER,
|
|
||||||
audience=settings.ADDONS_TOKEN_AUDIENCE,
|
|
||||||
expiration_seconds=settings.ADDONS_TOKEN_TTL,
|
|
||||||
token_type=settings.ADDONS_TOKEN_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _cache_key(prefix: str, token: str) -> str:
|
|
||||||
"""Build a namespaced cache key: ``addons_{prefix}_{token}``."""
|
|
||||||
return f"addons_{prefix}_{token}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _derive_csrf_token(session_id: str) -> str:
|
|
||||||
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET."""
|
|
||||||
return hmac.new(
|
|
||||||
settings.ADDONS_CSRF_SECRET.encode("utf-8"),
|
|
||||||
session_id.encode("utf-8"),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_session_not_expired(session_data: dict) -> int:
|
|
||||||
"""Return remaining seconds until expiry, or raise if missing/malformed/expired."""
|
|
||||||
expires_at_str = session_data.get("expires_at")
|
|
||||||
if expires_at_str is None:
|
|
||||||
raise SessionDataError("Invalid session data: missing expiration.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
expires_at = datetime.fromisoformat(expires_at_str)
|
|
||||||
except ValueError as e:
|
|
||||||
raise SessionDataError("Invalid session data: malformed expiration.") from e
|
|
||||||
|
|
||||||
remaining_seconds = int(
|
|
||||||
(expires_at - datetime.now(timezone.utc)).total_seconds()
|
|
||||||
)
|
|
||||||
|
|
||||||
if remaining_seconds <= 0:
|
|
||||||
raise SessionExpiredError("Session expired.")
|
|
||||||
|
|
||||||
return remaining_seconds
|
|
||||||
|
|
||||||
def _generate_session_id(self) -> str:
|
|
||||||
"""Generate a high-entropy URL-safe session_id."""
|
|
||||||
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
|
|
||||||
|
|
||||||
def _generate_transit_token(self) -> str:
|
|
||||||
"""Generate a high-entropy URL-safe transit token."""
|
|
||||||
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
|
|
||||||
|
|
||||||
def init_session(self) -> tuple[str, str, str]:
|
|
||||||
"""Create a new pending session and its transit binding.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(transit_token, session_id, csrf_token)
|
|
||||||
"""
|
|
||||||
session_id = self._generate_session_id()
|
|
||||||
transit_token = self._generate_transit_token()
|
|
||||||
csrf_token = self._derive_csrf_token(session_id)
|
|
||||||
|
|
||||||
expires_at = (
|
|
||||||
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL)
|
|
||||||
).isoformat()
|
|
||||||
|
|
||||||
session_data = {
|
|
||||||
"state": SessionState.PENDING,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"transit_token": transit_token,
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
|
||||||
session_data,
|
|
||||||
settings.ADDONS_SESSION_TTL,
|
|
||||||
)
|
|
||||||
|
|
||||||
transit_token_data = {
|
|
||||||
"session_id": session_id,
|
|
||||||
"state": TransitTokenState.PENDING,
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token),
|
|
||||||
transit_token_data,
|
|
||||||
settings.ADDONS_TRANSIT_TOKEN_TTL,
|
|
||||||
)
|
|
||||||
|
|
||||||
return transit_token, session_id, csrf_token
|
|
||||||
|
|
||||||
def verify_csrf(self, session_id: str, submitted_csrf: str) -> None:
|
|
||||||
"""Constant-time verify submitted_csrf against HMAC(session_id). Raise on mismatch."""
|
|
||||||
expected_csrf = self._derive_csrf_token(session_id)
|
|
||||||
if not hmac.compare_digest(expected_csrf, submitted_csrf):
|
|
||||||
raise CSRFTokenError("Invalid CSRF token.")
|
|
||||||
|
|
||||||
def consume_transit_token(self, transit_token: str) -> str:
|
|
||||||
"""Mark transit token consumed and return its session_id.
|
|
||||||
|
|
||||||
A replay (second consume) evicts the session as a security cleanup and raises.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TransitTokenError: If token is unknown, expired, or already consumed.
|
|
||||||
"""
|
|
||||||
cache_key = self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
|
||||||
|
|
||||||
transit_token_data = cache.get(cache_key)
|
|
||||||
|
|
||||||
if transit_token_data is None:
|
|
||||||
# Indistinguishable from here: either the token was never issued (attacker
|
|
||||||
# probing or client bug) or it was issued but expired before consumption.
|
|
||||||
logger.warning(
|
|
||||||
"Transit token not found in cache (unknown or expired).",
|
|
||||||
)
|
|
||||||
raise TransitTokenError("Invalid or expired transit token.")
|
|
||||||
|
|
||||||
state = transit_token_data.get("state", None)
|
|
||||||
session_id = transit_token_data.get("session_id", None)
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
logger.warning("Transit token data missing session_id.")
|
|
||||||
raise TransitTokenError("Invalid transit token.")
|
|
||||||
|
|
||||||
if state == TransitTokenState.CONSUMED:
|
|
||||||
logger.warning(
|
|
||||||
"Replay on session %s",
|
|
||||||
session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Security cleanup: a replay attempt means the transit token leaked
|
|
||||||
# (or an attacker is probing). Evict the session so the authenticated
|
|
||||||
# tokens — if they exist — can no longer be polled.
|
|
||||||
cache.delete(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
raise TransitTokenError("Transit token already consumed.")
|
|
||||||
|
|
||||||
new_transit_token_data = {
|
|
||||||
"state": TransitTokenState.CONSUMED,
|
|
||||||
"session_id": session_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(
|
|
||||||
cache_key,
|
|
||||||
new_transit_token_data,
|
|
||||||
settings.ADDONS_SESSION_TTL,
|
|
||||||
)
|
|
||||||
|
|
||||||
return session_id
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def is_session_pending(session_data: dict) -> bool:
|
|
||||||
"""Return True if the public session dict is still in the pending state."""
|
|
||||||
return session_data.get("state") == SessionState.PENDING
|
|
||||||
|
|
||||||
def _get_session_data(self, session_id: str) -> dict:
|
|
||||||
"""Fetch raw session data from cache, or raise SessionNotFoundError."""
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
raise SessionNotFoundError("Session not found.")
|
|
||||||
|
|
||||||
data = cache.get(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
|
||||||
)
|
|
||||||
if data is None:
|
|
||||||
raise SessionNotFoundError("Session not found.")
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> dict:
|
|
||||||
"""Return the public session view; evict the session on authenticated read.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SessionNotFoundError: If session is not found.
|
|
||||||
SessionDataError: If session data is missing the state field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# raises if session is not found
|
|
||||||
session_data = self._get_session_data(session_id)
|
|
||||||
|
|
||||||
if "state" not in session_data:
|
|
||||||
raise SessionDataError("Invalid session data: missing state field.")
|
|
||||||
|
|
||||||
# One-time read: clear both bindings for authenticated sessions
|
|
||||||
if session_data["state"] == SessionState.AUTHENTICATED:
|
|
||||||
cache.delete(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return public fields only
|
|
||||||
return {k: v for k, v in session_data.items() if k in _PUBLIC_SESSION_FIELDS}
|
|
||||||
|
|
||||||
def _validate_transit_token_state(self, session_data: dict) -> None:
|
|
||||||
"""Assert the session's transit token exists in cache and is in CONSUMED state.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SessionDataError: session_data is missing the transit_token field.
|
|
||||||
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
|
|
||||||
"""
|
|
||||||
|
|
||||||
transit_token = session_data.get("transit_token", None)
|
|
||||||
if transit_token is None:
|
|
||||||
raise SessionDataError("Invalid session data: missing transit_token field.")
|
|
||||||
|
|
||||||
transit_token_data = cache.get(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
|
||||||
)
|
|
||||||
|
|
||||||
if transit_token_data is None:
|
|
||||||
logger.warning("Transit token missing when setting access token.")
|
|
||||||
raise SuspiciousSessionError("Transit token not found.")
|
|
||||||
|
|
||||||
if transit_token_data.get("state") != TransitTokenState.CONSUMED:
|
|
||||||
logger.warning("Access token requested without completing transit flow.")
|
|
||||||
raise SuspiciousSessionError("Transit token not consumed.")
|
|
||||||
|
|
||||||
def set_access_token(self, user: User, session_id: str) -> None:
|
|
||||||
"""Authenticate a pending session by minting a JWT and storing it on the session.
|
|
||||||
|
|
||||||
Non-pending sessions are evicted as a security cleanup before raising.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SessionNotFoundError: If session doesn't exist.
|
|
||||||
SessionDataError: If session data is malformed.
|
|
||||||
SessionExpiredError: If session has expired.
|
|
||||||
SuspiciousSessionError: If session is not pending or transit wasn't consumed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# raises if session is not found
|
|
||||||
session_data = self._get_session_data(session_id)
|
|
||||||
|
|
||||||
if session_data.get("state") != SessionState.PENDING:
|
|
||||||
logger.warning(
|
|
||||||
"Session's state is not pending. Suspicious.",
|
|
||||||
)
|
|
||||||
# Security cleanup: evict the session so any cached tokens cannot be polled.
|
|
||||||
cache.delete(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
|
||||||
)
|
|
||||||
raise SuspiciousSessionError("Session is not in pending state.")
|
|
||||||
|
|
||||||
# raises if transit_token is invalid
|
|
||||||
try:
|
|
||||||
self._validate_transit_token_state(session_data)
|
|
||||||
except SuspiciousSessionError:
|
|
||||||
# Security cleanup: evict the session.
|
|
||||||
cache.delete(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# raises if session is expired
|
|
||||||
remaining_seconds = self._validate_session_not_expired(session_data)
|
|
||||||
|
|
||||||
response = self._token_service.generate_jwt(user, settings.ADDONS_TOKEN_SCOPE)
|
|
||||||
|
|
||||||
new_data = {
|
|
||||||
"access_token": response["access_token"],
|
|
||||||
"token_type": response["token_type"],
|
|
||||||
"expires_in": response["expires_in"],
|
|
||||||
"scope": response["scope"],
|
|
||||||
"expires_at": session_data["expires_at"],
|
|
||||||
"state": SessionState.AUTHENTICATED,
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(
|
|
||||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
|
||||||
new_data,
|
|
||||||
remaining_seconds,
|
|
||||||
)
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
"""Add-ons API endpoints"""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
|
|
||||||
from rest_framework import decorators, viewsets
|
|
||||||
from rest_framework import (
|
|
||||||
response as drf_response,
|
|
||||||
)
|
|
||||||
from rest_framework import status as drf_status
|
|
||||||
|
|
||||||
from core.addons.service import (
|
|
||||||
CSRFTokenError,
|
|
||||||
SessionDataError,
|
|
||||||
SessionExpiredError,
|
|
||||||
SessionNotFoundError,
|
|
||||||
SuspiciousSessionError,
|
|
||||||
TokenExchangeService,
|
|
||||||
TransitTokenError,
|
|
||||||
)
|
|
||||||
from core.api.feature_flag import FeatureFlag
|
|
||||||
from core.api.permissions import IsAuthenticated
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionViewSet(viewsets.ViewSet):
|
|
||||||
"""ViewSet for managing add-on authentication sessions via token exchange.
|
|
||||||
|
|
||||||
Implements a three-step flow that lets a third-party add-on (running in an
|
|
||||||
embedded iframe) obtain an access token without exposing it to client-side
|
|
||||||
JavaScript:
|
|
||||||
|
|
||||||
1. /init: the add-on opens a session and receives a short-lived transit
|
|
||||||
token (used to bootstrap the OAuth-style exchange in a dialog) and a
|
|
||||||
CSRF token. The opaque session id is stored in an HttpOnly, Secure,
|
|
||||||
SameSite=None cookie so it can accompany cross-origin polls.
|
|
||||||
2. /poll: the add-on polls until the session transitions from pending to
|
|
||||||
authenticated. On the terminal read, the session payload (access
|
|
||||||
token, token type, expiry, etc.) is returned, the session is evicted
|
|
||||||
server-side, and the session cookie is cleared so the tokens can be
|
|
||||||
retrieved exactly once.
|
|
||||||
3. /exchange: called from the post-login callback page on our own domain,
|
|
||||||
after the user has authenticated in a dialog opened by the addon. The
|
|
||||||
transit token (carried client-side via postMessage + sessionStorage)
|
|
||||||
is redeemed here for the authenticated user's access token, which is
|
|
||||||
stored server-side against the session. Requires an authenticated
|
|
||||||
user — that user is whose access token gets bound to the session.
|
|
||||||
|
|
||||||
/init and /poll authenticate the caller through the session cookie +
|
|
||||||
CSRF token pair alone — no user login is required, since the whole point
|
|
||||||
of the flow is to bootstrap one. /exchange, by contrast, requires an
|
|
||||||
authenticated user and does not use the addonsSid cookie.
|
|
||||||
"""
|
|
||||||
|
|
||||||
throttle_classes = []
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["POST"],
|
|
||||||
url_path="init",
|
|
||||||
authentication_classes=[],
|
|
||||||
permission_classes=[],
|
|
||||||
)
|
|
||||||
@FeatureFlag.require("addons")
|
|
||||||
def init(self, request):
|
|
||||||
"""Open a new add-on authentication session.
|
|
||||||
|
|
||||||
Creates a fresh session server-side and returns the credentials the
|
|
||||||
add-on needs to drive the rest of the flow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
|
|
||||||
|
|
||||||
response = drf_response.Response(
|
|
||||||
{"transit_token": transit_token, "csrf_token": csrf_token},
|
|
||||||
status=drf_status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
|
|
||||||
# SameSite=None allows the cookie to be sent on cross-origin requests,
|
|
||||||
# which is required because the /poll endpoint is called from an iframe
|
|
||||||
# embedded in a third-party site. Secure=True is mandatory when SameSite=None.
|
|
||||||
# HttpOnly prevents JS access, so the cookie can only be read by the server.
|
|
||||||
response.set_cookie(
|
|
||||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
|
||||||
value=session_id,
|
|
||||||
max_age=settings.ADDONS_SESSION_TTL,
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="None",
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["POST"],
|
|
||||||
url_path="poll",
|
|
||||||
authentication_classes=[],
|
|
||||||
permission_classes=[],
|
|
||||||
)
|
|
||||||
@FeatureFlag.require("addons")
|
|
||||||
def poll(self, request):
|
|
||||||
"""Poll a session for its current state and, if terminal, consume it.
|
|
||||||
|
|
||||||
Authenticates the caller using the addonsSid cookie (set by
|
|
||||||
/init) together with the X-CSRFToken header, which must match
|
|
||||||
the CSRF token issued for that session. The session id alone is not
|
|
||||||
sufficient — both must be presented and must correspond.
|
|
||||||
|
|
||||||
Behavior depends on the session's current state:
|
|
||||||
|
|
||||||
- **Pending**: the token exchange has not yet completed. Returns
|
|
||||||
202 Accepted with `{"state": "pending"}`. The cookie is preserved
|
|
||||||
so the add-on can keep polling.
|
|
||||||
- **Authenticated** (or any other terminal state): returns 200 OK
|
|
||||||
with the session payload (access token, token type, expiry, etc.)
|
|
||||||
and clears the `addonsSid` cookie. The session is also evicted
|
|
||||||
server-side on this terminal read, so the tokens can be retrieved
|
|
||||||
exactly once.
|
|
||||||
|
|
||||||
A CSRF mismatch is treated as a `SuspiciousOperation` rather than a
|
|
||||||
normal 4xx, so it is logged by Django's security middleware and
|
|
||||||
surfaced as a 400 without leaking which check failed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE)
|
|
||||||
submitted_csrf = request.headers.get("X-CSRFToken")
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Missing credentials."},
|
|
||||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not submitted_csrf:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Missing CSRF token."},
|
|
||||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
service = TokenExchangeService()
|
|
||||||
|
|
||||||
try:
|
|
||||||
service.verify_csrf(session_id, submitted_csrf)
|
|
||||||
except CSRFTokenError as e:
|
|
||||||
raise SuspiciousOperation(str(e)) from e
|
|
||||||
|
|
||||||
try:
|
|
||||||
session = service.get_session(session_id)
|
|
||||||
except SessionNotFoundError:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Session not found."},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except SessionDataError:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Invalid or expired session."},
|
|
||||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
if service.is_session_pending(session):
|
|
||||||
return drf_response.Response(
|
|
||||||
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
|
|
||||||
)
|
|
||||||
|
|
||||||
response = drf_response.Response(session, status=drf_status.HTTP_200_OK)
|
|
||||||
response.delete_cookie(
|
|
||||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
|
||||||
samesite="None",
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["POST"],
|
|
||||||
url_path="exchange",
|
|
||||||
permission_classes=[IsAuthenticated],
|
|
||||||
)
|
|
||||||
@FeatureFlag.require("addons")
|
|
||||||
def exchange(self, request):
|
|
||||||
"""Redeem a transit token for an access token bound to the current user.
|
|
||||||
|
|
||||||
Called from the post-OIDC callback page on our own domain. The transit
|
|
||||||
token was issued by /init, passed to the authentication dialog via
|
|
||||||
postMessage, stashed in sessionStorage, and read back by this page
|
|
||||||
after login completes.
|
|
||||||
|
|
||||||
The authenticated user (request.user) is whose access token gets stored
|
|
||||||
against the session. On success, the addon's next /poll will transition
|
|
||||||
from pending to authenticated and receive the token payload.
|
|
||||||
|
|
||||||
Transit tokens are single-use: a replayed token is rejected with 400.
|
|
||||||
"""
|
|
||||||
|
|
||||||
transit_token = request.data.get("transit_token")
|
|
||||||
if not transit_token:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Missing transit_token."},
|
|
||||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
service = TokenExchangeService()
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_id = service.consume_transit_token(transit_token)
|
|
||||||
except TransitTokenError:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Invalid or expired transit token."},
|
|
||||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
service.set_access_token(request.user, session_id)
|
|
||||||
except SessionNotFoundError:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Session not found."},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except (SessionDataError, SessionExpiredError, SuspiciousSessionError):
|
|
||||||
return drf_response.Response(
|
|
||||||
{"detail": "Invalid or expired session."},
|
|
||||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)
|
|
||||||
@@ -197,38 +197,6 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
|
|
||||||
def mark_as_failed_to_stop(modeladmin, request, queryset):
|
|
||||||
"""Force selected recordings status to failed_to_stop."""
|
|
||||||
|
|
||||||
eligible_statuses = [
|
|
||||||
models.RecordingStatusChoices.ACTIVE,
|
|
||||||
models.RecordingStatusChoices.INITIATED,
|
|
||||||
models.RecordingStatusChoices.STOPPED,
|
|
||||||
]
|
|
||||||
|
|
||||||
eligible = queryset.filter(status__in=eligible_statuses)
|
|
||||||
skipped = queryset.exclude(status__in=eligible_statuses).count()
|
|
||||||
|
|
||||||
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
|
|
||||||
|
|
||||||
if updated > 0:
|
|
||||||
modeladmin.message_user(
|
|
||||||
request,
|
|
||||||
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
|
|
||||||
% {"count": updated},
|
|
||||||
level=messages.SUCCESS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if skipped > 0:
|
|
||||||
modeladmin.message_user(
|
|
||||||
request,
|
|
||||||
_("Skipped %(count)s recording(s) with an ineligible status.")
|
|
||||||
% {"count": skipped},
|
|
||||||
level=messages.WARNING,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Recording)
|
@admin.register(models.Recording)
|
||||||
class RecordingAdmin(admin.ModelAdmin):
|
class RecordingAdmin(admin.ModelAdmin):
|
||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
@@ -256,7 +224,7 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"worker_id",
|
"worker_id",
|
||||||
)
|
)
|
||||||
actions = [resend_notification, mark_as_failed_to_stop]
|
actions = [resend_notification]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||||
@@ -308,7 +276,7 @@ class ApplicationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
form = ApplicationAdminForm
|
form = ApplicationAdminForm
|
||||||
|
|
||||||
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
|
list_display = ("id", "name", "client_id", "get_scopes_display")
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
"id",
|
"id",
|
||||||
@@ -317,7 +285,6 @@ class ApplicationAdmin(admin.ModelAdmin):
|
|||||||
"scopes",
|
"scopes",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
"is_active",
|
|
||||||
]
|
]
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
readonly_fields = ["id", "created_at", "updated_at"]
|
||||||
inlines = [ApplicationDomainInline]
|
inlines = [ApplicationDomainInline]
|
||||||
|
|||||||
@@ -43,21 +43,6 @@ def get_frontend_configuration(request):
|
|||||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||||
},
|
},
|
||||||
"background_image": {
|
|
||||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
|
||||||
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
|
||||||
"max_count_by_user"
|
|
||||||
],
|
|
||||||
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
|
||||||
"max_size"
|
|
||||||
],
|
|
||||||
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
|
||||||
"allowed_extensions"
|
|
||||||
],
|
|
||||||
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
|
|
||||||
"allowed_mimetypes"
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"telephony": {
|
"telephony": {
|
||||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ class FeatureFlag:
|
|||||||
"recording": "RECORDING_ENABLE",
|
"recording": "RECORDING_ENABLE",
|
||||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
|
||||||
"addons": "ADDONS_ENABLED",
|
|
||||||
"application": "APPLICATION_ENABLED",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"""API filters for meet' core application."""
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
import django_filters
|
|
||||||
from django_filters import BooleanFilter
|
|
||||||
|
|
||||||
from core import models
|
|
||||||
|
|
||||||
|
|
||||||
class FileFilter(django_filters.FilterSet):
|
|
||||||
"""
|
|
||||||
Custom filter for filtering files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.File
|
|
||||||
fields = ["type"]
|
|
||||||
|
|
||||||
|
|
||||||
class ListFileFilter(FileFilter):
|
|
||||||
"""Filter class dedicated to the file viewset list method."""
|
|
||||||
|
|
||||||
is_creator_me = django_filters.BooleanFilter(
|
|
||||||
method="filter_is_creator_me", label=_("Creator is me")
|
|
||||||
)
|
|
||||||
|
|
||||||
is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.File
|
|
||||||
fields = ["is_creator_me", "type", "upload_state", "is_deleted"]
|
|
||||||
|
|
||||||
def filter_is_deleted(self, queryset, name, value):
|
|
||||||
"""
|
|
||||||
Filter files based on whether they are deleted or not.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- /api/v1.0/files/?is_deleted=false
|
|
||||||
→ Filters files that were not deleted
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
lookup = "__".join([name, "isnull"])
|
|
||||||
return queryset.filter(**{lookup: not value})
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_is_creator_me(self, queryset, name, value):
|
|
||||||
"""
|
|
||||||
Filter files based on the `creator` being the current user.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- /api/v1.0/files/?is_creator_me=true
|
|
||||||
→ Filters files created by the logged-in user
|
|
||||||
- /api/v1.0/files/?is_creator_me=false
|
|
||||||
→ Filters files created by other users
|
|
||||||
"""
|
|
||||||
user = self.request.user
|
|
||||||
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
if value:
|
|
||||||
return queryset.filter(creator=user)
|
|
||||||
|
|
||||||
return queryset.exclude(creator=user)
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
"""Permission handlers for the Meet core app."""
|
"""Permission handlers for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import Http404
|
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
@@ -109,30 +106,3 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
|
|||||||
if not request.auth or not hasattr(request.auth, "video"):
|
if not request.auth or not hasattr(request.auth, "video"):
|
||||||
return False
|
return False
|
||||||
return request.auth.video.room == str(obj.id)
|
return request.auth.video.room == str(obj.id)
|
||||||
|
|
||||||
|
|
||||||
class FilePermission(IsAuthenticated):
|
|
||||||
"""
|
|
||||||
Permissions applying to the file API endpoint.
|
|
||||||
Handling soft deletions specificities
|
|
||||||
"""
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
"""Allow access only to authenticated users."""
|
|
||||||
if not settings.FILE_UPLOAD_ENABLED:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
return super().has_permission(request, view)
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
"""
|
|
||||||
Return a 404 on deleted files or if the user is not the owner
|
|
||||||
"""
|
|
||||||
|
|
||||||
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
if obj.creator != request.user:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
return obj.get_abilities(request.user).get(view.action, False)
|
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
"""Client serializers for the Meet core app."""
|
"""Client serializers for the Meet core app."""
|
||||||
|
|
||||||
# pylint: disable=abstract-method,no-name-in-module
|
# pylint: disable=abstract-method,no-name-in-module
|
||||||
import logging
|
|
||||||
from os.path import splitext
|
|
||||||
from typing import Literal
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
|
|
||||||
# pylint: disable=abstract-method,no-name-in-module
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from livekit.api import ParticipantPermission
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
@@ -34,15 +23,6 @@ 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
|
||||||
@@ -221,30 +201,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
|
||||||
"""Configuration options for recording.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
language: ISO 639-1 language code compatible with whisperX.
|
|
||||||
When `None`, the transcription engine will attempt to
|
|
||||||
auto-detect the spoken language.
|
|
||||||
transcribe: Whether to transcribe the recorded audio.
|
|
||||||
When `None`, falls back to the application default.
|
|
||||||
original_mode: The original recording mode before any override.
|
|
||||||
Must be one of the valid RecordingModeChoices values when provided.
|
|
||||||
collect_metadata: Whether to collect additional metadata during recording.
|
|
||||||
When `None`, no metadata are collected.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
language: str | None = None
|
|
||||||
transcribe: bool | None = None
|
|
||||||
collect_metadata: bool | None = None
|
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
|
||||||
|
|
||||||
model_config = {"extra": "forbid"}
|
|
||||||
|
|
||||||
|
|
||||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate start recording requests."""
|
"""Validate start recording requests."""
|
||||||
|
|
||||||
@@ -257,11 +213,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
"screen_recording or transcript.",
|
"screen_recording or transcript.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
options = SchemaField(
|
options = serializers.JSONField(
|
||||||
schema=RecordingOptions | None,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Recording options",
|
default=dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -306,29 +261,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
|
||||||
|
|
||||||
|
|
||||||
class ParticipantPermission(BaseModel):
|
|
||||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
|
||||||
|
|
||||||
Control what a participant is allowed to publish, subscribe, and do within a room.
|
|
||||||
Unknown fields are rejected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
can_subscribe: bool | None = None
|
|
||||||
can_publish: bool | None = None
|
|
||||||
can_publish_data: bool | None = None
|
|
||||||
can_publish_sources: list[TrackSource] = Field(default_factory=list)
|
|
||||||
hidden: bool | None = None
|
|
||||||
recorder: bool | None = None
|
|
||||||
can_update_metadata: bool | None = None
|
|
||||||
agent: bool | None = None
|
|
||||||
can_subscribe_metrics: bool | None = None
|
|
||||||
|
|
||||||
model_config = {"extra": "forbid"}
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||||
"""Validate participant update data."""
|
"""Validate participant update data."""
|
||||||
|
|
||||||
@@ -340,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant attributes as JSON object",
|
help_text="Participant attributes as JSON object",
|
||||||
)
|
)
|
||||||
permission = SchemaField(
|
permission = serializers.DictField(
|
||||||
schema=ParticipantPermission | None,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant permissions",
|
help_text="Participant permission as JSON object",
|
||||||
)
|
)
|
||||||
name = serializers.CharField(
|
name = serializers.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -354,25 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
help_text="Display name for the participant",
|
help_text="Display name for the participant",
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_permission(self, permission):
|
|
||||||
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
|
||||||
|
|
||||||
if permission is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
suspicious_fields = [
|
|
||||||
field
|
|
||||||
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
|
||||||
if getattr(permission, field) is not None
|
|
||||||
]
|
|
||||||
if suspicious_fields:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
f"Setting the following participant permissions is not allowed: "
|
|
||||||
f"{', '.join(suspicious_fields)}."
|
|
||||||
)
|
|
||||||
|
|
||||||
return permission
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Ensure at least one update field is provided."""
|
"""Ensure at least one update field is provided."""
|
||||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||||
@@ -388,156 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
f"{', '.join(update_fields)}."
|
f"{', '.join(update_fields)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
return attrs
|
if "permission" in attrs:
|
||||||
|
try:
|
||||||
|
ParticipantPermission(**attrs["permission"])
|
||||||
class ListFileSerializer(serializers.ModelSerializer):
|
except ValueError as e:
|
||||||
"""Serialize File model for the API."""
|
|
||||||
|
|
||||||
url = serializers.SerializerMethodField(read_only=True)
|
|
||||||
creator = UserLightSerializer(read_only=True)
|
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.File
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"title",
|
|
||||||
"type",
|
|
||||||
"creator",
|
|
||||||
"deleted_at",
|
|
||||||
"hard_deleted_at",
|
|
||||||
"filename",
|
|
||||||
"upload_state",
|
|
||||||
"mimetype",
|
|
||||||
"size",
|
|
||||||
"description",
|
|
||||||
"url",
|
|
||||||
"abilities",
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
"id",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"creator",
|
|
||||||
"deleted_at",
|
|
||||||
"hard_deleted_at",
|
|
||||||
"filename",
|
|
||||||
"upload_state",
|
|
||||||
"mimetype",
|
|
||||||
"size",
|
|
||||||
"url",
|
|
||||||
"abilities",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_url(self, obj):
|
|
||||||
"""Return the URL of the file."""
|
|
||||||
if obj.is_pending_upload:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
|
|
||||||
|
|
||||||
def get_abilities(self, file) -> dict:
|
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
|
||||||
request = self.context.get("request")
|
|
||||||
if not request:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return file.get_abilities(request.user)
|
|
||||||
|
|
||||||
|
|
||||||
class FileSerializer(ListFileSerializer):
|
|
||||||
"""Default serializer File model for the API."""
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
raise NotImplementedError("Create method can not be used.")
|
|
||||||
|
|
||||||
|
|
||||||
class CreateFileSerializer(ListFileSerializer):
|
|
||||||
"""Serializer used to create a new file"""
|
|
||||||
|
|
||||||
title = serializers.CharField(max_length=255, required=False)
|
|
||||||
policy = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.File
|
|
||||||
fields = [*ListFileSerializer.Meta.fields, "policy"]
|
|
||||||
read_only_fields = [
|
|
||||||
*(
|
|
||||||
field
|
|
||||||
for field in ListFileSerializer.Meta.read_only_fields
|
|
||||||
if field != "filename"
|
|
||||||
),
|
|
||||||
"policy",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_fields(self):
|
|
||||||
"""Force the id field to be writable."""
|
|
||||||
fields = super().get_fields()
|
|
||||||
fields["id"].read_only = False
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def validate_id(self, value):
|
|
||||||
"""Ensure the provided ID does not already exist when creating a new file."""
|
|
||||||
request = self.context.get("request")
|
|
||||||
|
|
||||||
# Only check this on POST (creation)
|
|
||||||
if request and models.File.objects.filter(id=value).exists():
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"A file with this ID already exists. You cannot override it.",
|
|
||||||
code="file_create_existing_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
"""Validate extension and fill title."""
|
|
||||||
# we run the default validation first to make sure the base data in attrs is ok
|
|
||||||
attrs = super().validate(attrs)
|
|
||||||
|
|
||||||
filename_root, ext = splitext(attrs["filename"])
|
|
||||||
|
|
||||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
|
||||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]]
|
|
||||||
if ext.lower() not in config_for_file_type["allowed_extensions"]:
|
|
||||||
logger.info(
|
|
||||||
"create_item: file extension not allowed %s for filename %s",
|
|
||||||
ext,
|
|
||||||
attrs["filename"],
|
|
||||||
)
|
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"filename": _("This file extension is not allowed.")},
|
{"permission": f"Invalid permission: {str(e)}"}
|
||||||
code="item_create_file_extension_not_allowed",
|
) from e
|
||||||
)
|
|
||||||
|
|
||||||
# The title will be the filename if not provided
|
|
||||||
if not attrs.get("title", None):
|
|
||||||
attrs["title"] = filename_root
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def get_policy(self, file):
|
|
||||||
"""Return the policy to use if the item is a file."""
|
|
||||||
|
|
||||||
if file.upload_state == models.FileUploadStateChoices.READY:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return utils.generate_upload_policy(file)
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
raise NotImplementedError("Update method can not be used.")
|
|
||||||
|
|
||||||
|
|
||||||
class RaiseHandSerializer(BaseValidationOnlySerializer):
|
|
||||||
"""Serializer for raising or lowering a participant's hand in a room."""
|
|
||||||
|
|
||||||
raised = serializers.BooleanField()
|
|
||||||
|
|
||||||
|
|
||||||
class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
|
||||||
"""Serializer for renaming a participant in a room."""
|
|
||||||
|
|
||||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Throttling modules for the API."""
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
|
|
||||||
@@ -16,58 +14,11 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
|||||||
"""Throttle for the monitored scoped rate throttle."""
|
"""Throttle for the monitored scoped rate throttle."""
|
||||||
|
|
||||||
|
|
||||||
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
|
|
||||||
"""Throttle for the monitored scoped rate throttle."""
|
|
||||||
|
|
||||||
|
|
||||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
|
||||||
"""Throttle authenticated user requesting room entry"""
|
|
||||||
|
|
||||||
scope = "request_entry"
|
|
||||||
|
|
||||||
def get_cache_key(self, request, view):
|
|
||||||
"""Use the authenticated user ID as the throttle cache key."""
|
|
||||||
|
|
||||||
if request.user and not request.user.is_authenticated:
|
|
||||||
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
|
|
||||||
|
|
||||||
return super().get_cache_key(request, view)
|
|
||||||
|
|
||||||
|
|
||||||
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
"""Throttle Anonymous user requesting room entry"""
|
"""Throttle Anonymous user requesting room entry"""
|
||||||
|
|
||||||
scope = "request_entry"
|
scope = "request_entry"
|
||||||
|
|
||||||
def get_cache_key(self, request, view):
|
|
||||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
|
||||||
|
|
||||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
|
||||||
return None to skip throttling — the cookie will be set on the first
|
|
||||||
response, and throttling will apply from the second request onward.
|
|
||||||
|
|
||||||
Keying on the cookie rather than the IP address prevents penalising
|
|
||||||
multiple users behind the same NAT/proxy, and is consistent with how
|
|
||||||
LobbyService identifies participants.
|
|
||||||
|
|
||||||
Note: as per DRF documentation, application-level throttling is not a
|
|
||||||
security measure against brute-force or DoS attacks. This throttle exists
|
|
||||||
solely to guard against accidental hammering from buggy clients.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if request.user and request.user.is_authenticated:
|
|
||||||
return None # Only throttle unauthenticated requests.
|
|
||||||
|
|
||||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
|
|
||||||
if participant_id is None:
|
|
||||||
return None # No throttling for cookieless requests
|
|
||||||
|
|
||||||
return self.cache_format % {
|
|
||||||
"scope": self.scope,
|
|
||||||
"ident": participant_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
# pylint: disable=too-many-lines
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from django_filters import rest_framework as django_filters
|
from rest_framework import decorators, mixins, pagination, viewsets
|
||||||
from rest_framework import (
|
|
||||||
decorators,
|
|
||||||
filters,
|
|
||||||
mixins,
|
|
||||||
pagination,
|
|
||||||
viewsets,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
@@ -33,22 +22,15 @@ 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_collector import (
|
|
||||||
MetadataCollectorException,
|
|
||||||
MetadataCollectorService,
|
|
||||||
)
|
|
||||||
from core.recording.worker.exceptions import (
|
from core.recording.worker.exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
@@ -69,13 +51,11 @@ from core.services.lobby import (
|
|||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
from core.services.participants_management import (
|
from core.services.participants_management import (
|
||||||
ParticipantNotFoundException,
|
|
||||||
ParticipantsManagement,
|
ParticipantsManagement,
|
||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
from core.services.room_creation import RoomCreation
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
from core.tasks.file import process_file_deletion
|
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
@@ -97,20 +77,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
|
|||||||
lookup_fields: list[str] = ["pk"]
|
lookup_fields: list[str] = ["pk"]
|
||||||
lookup_url_kwargs: list[str] = []
|
lookup_url_kwargs: list[str] = []
|
||||||
|
|
||||||
def __getattribute__(self, file):
|
def __getattribute__(self, item):
|
||||||
"""
|
"""
|
||||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||||
"""
|
"""
|
||||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||||
return getattr(self, file + "s", [None])[-1]
|
return getattr(self, item + "s", [None])[-1]
|
||||||
|
|
||||||
return super().__getattribute__(file)
|
return super().__getattribute__(item)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Get the list of files for this view.
|
Get the list of items for this view.
|
||||||
|
|
||||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||||
"""
|
"""
|
||||||
@@ -221,7 +201,6 @@ class RoomViewSet(
|
|||||||
API endpoints to access and perform actions on rooms.
|
API endpoints to access and perform actions on rooms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pagination_class = Pagination
|
|
||||||
permission_classes = [permissions.RoomPermissions]
|
permission_classes = [permissions.RoomPermissions]
|
||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
@@ -317,14 +296,12 @@ class RoomViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data["options"]
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
# May raise exception if an active or initiated recording already exist for the room
|
# May raise exception if an active or initiated recording already exist for the room
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room, mode=mode, options=options
|
||||||
mode=mode,
|
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
@@ -342,14 +319,6 @@ class RoomViewSet(
|
|||||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.METADATA_COLLECTOR_ENABLED and (
|
|
||||||
recording.options.get("collect_metadata", False)
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
MetadataCollectorService().start(recording)
|
|
||||||
except MetadataCollectorException:
|
|
||||||
logger.warning("Failed to start MetadataCollectorService")
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -398,10 +367,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="request-entry",
|
url_path="request-entry",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[
|
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
||||||
throttling.RequestEntryAuthenticatedUserRateThrottle,
|
|
||||||
throttling.RequestEntryAnonRateThrottle,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
"""Request entry to a room"""
|
"""Request entry to a room"""
|
||||||
@@ -502,7 +468,9 @@ class RoomViewSet(
|
|||||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
return drf_response.Response({"status": "error"}, status=status_code)
|
return drf_response.Response(
|
||||||
|
{"status": "error", "message": str(e)}, status=status_code
|
||||||
|
)
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=False,
|
detail=False,
|
||||||
@@ -612,11 +580,6 @@ class RoomViewSet(
|
|||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
track_sid=serializer.validated_data["track_sid"],
|
track_sid=serializer.validated_data["track_sid"],
|
||||||
)
|
)
|
||||||
except ParticipantNotFoundException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Participant not found"},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"error": "Failed to mute participant"},
|
{"error": "Failed to mute participant"},
|
||||||
@@ -644,22 +607,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=permission.model_dump() if permission else None,
|
permission=serializer.validated_data.get("permission"),
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
)
|
)
|
||||||
except ParticipantNotFoundException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Participant not found"},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"error": "Failed to update participant"},
|
{"error": "Failed to update participant"},
|
||||||
@@ -692,11 +648,6 @@ class RoomViewSet(
|
|||||||
room_name=str(room.pk),
|
room_name=str(room.pk),
|
||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
)
|
)
|
||||||
except ParticipantNotFoundException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Participant not found"},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"error": "Failed to remove participant"},
|
{"error": "Failed to remove participant"},
|
||||||
@@ -707,92 +658,6 @@ class RoomViewSet(
|
|||||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=True,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="toggle-hand",
|
|
||||||
url_name="toggle-hand",
|
|
||||||
permission_classes=[permissions.HasLiveKitRoomAccess],
|
|
||||||
authentication_classes=[LiveKitTokenAuthentication],
|
|
||||||
)
|
|
||||||
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
|
|
||||||
"""Raise or lower the current participant's hand in the room."""
|
|
||||||
room = self.get_object()
|
|
||||||
|
|
||||||
serializer = serializers.RaiseHandSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
identity = request.auth.identity
|
|
||||||
|
|
||||||
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
|
|
||||||
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
|
|
||||||
# means the hand is raised. The timestamp is used by clients to determine
|
|
||||||
# the order in which participants raised their hands.
|
|
||||||
hand_raised_at = (
|
|
||||||
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ParticipantsManagement().update(
|
|
||||||
room_name=str(room.pk),
|
|
||||||
identity=identity,
|
|
||||||
attributes={"handRaisedAt": hand_raised_at},
|
|
||||||
)
|
|
||||||
except ParticipantNotFoundException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Participant not found"},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except ParticipantsManagementException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Failed to update participant hand state"},
|
|
||||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response(
|
|
||||||
{"status": "success"},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=True,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="rename",
|
|
||||||
url_name="rename",
|
|
||||||
permission_classes=[permissions.HasLiveKitRoomAccess],
|
|
||||||
authentication_classes=[LiveKitTokenAuthentication],
|
|
||||||
)
|
|
||||||
def rename(self, request, pk=None): # pylint: disable=unused-argument
|
|
||||||
"""Rename the current participant in the room."""
|
|
||||||
room = self.get_object()
|
|
||||||
|
|
||||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
identity = request.auth.identity
|
|
||||||
|
|
||||||
try:
|
|
||||||
ParticipantsManagement().update(
|
|
||||||
room_name=str(room.pk),
|
|
||||||
identity=identity,
|
|
||||||
name=serializer.validated_data["name"],
|
|
||||||
)
|
|
||||||
except ParticipantNotFoundException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Participant not found"},
|
|
||||||
status=drf_status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
|
||||||
except ParticipantsManagementException:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "Failed to rename participant"},
|
|
||||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response(
|
|
||||||
{"status": "success"},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessViewSet(
|
class ResourceAccessViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
@@ -870,19 +735,14 @@ class RecordingViewSet(
|
|||||||
recording_id = parser.get_recording_id(request.data)
|
recording_id = parser.get_recording_id(request.data)
|
||||||
|
|
||||||
except ParsingEventDataError as e:
|
except ParsingEventDataError as e:
|
||||||
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
||||||
|
|
||||||
except InvalidBucketError as e:
|
except InvalidBucketError as e:
|
||||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||||
|
|
||||||
except InvalidFilepathError:
|
except InvalidFileTypeError as e:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"message": "Notification ignored."},
|
{"message": f"Ignore this file type, {e}"},
|
||||||
)
|
|
||||||
|
|
||||||
except InvalidFileTypeError:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"message": "Notification ignored."},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -929,7 +789,7 @@ class RecordingViewSet(
|
|||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
logger.debug("Original url: '%s'", original_url)
|
logger.debug("Original url: '%s'", original_url)
|
||||||
@@ -946,7 +806,7 @@ class RecordingViewSet(
|
|||||||
try:
|
try:
|
||||||
return match.groupdict()
|
return match.groupdict()
|
||||||
except (ValueError, AttributeError) as exc:
|
except (ValueError, AttributeError) as exc:
|
||||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||||
raise drf_exceptions.PermissionDenied() from exc
|
raise drf_exceptions.PermissionDenied() from exc
|
||||||
|
|
||||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||||
@@ -970,7 +830,7 @@ class RecordingViewSet(
|
|||||||
recording_id = url_params["recording_id"]
|
recording_id = url_params["recording_id"]
|
||||||
|
|
||||||
extension = url_params["extension"]
|
extension = url_params["extension"]
|
||||||
if extension not in [file.value for file in FileExtension]:
|
if extension not in [item.value for item in FileExtension]:
|
||||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -994,324 +854,3 @@ class RecordingViewSet(
|
|||||||
request = utils.generate_s3_authorization_headers(recording.key)
|
request = utils.generate_s3_authorization_headers(recording.key)
|
||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
|
||||||
class FileViewSet(
|
|
||||||
SerializerPerActionMixin,
|
|
||||||
mixins.CreateModelMixin,
|
|
||||||
mixins.DestroyModelMixin,
|
|
||||||
mixins.UpdateModelMixin,
|
|
||||||
mixins.ListModelMixin,
|
|
||||||
viewsets.GenericViewSet,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
FileViewSet API.
|
|
||||||
|
|
||||||
This viewset provides CRUD operations and additional actions for managing files.
|
|
||||||
|
|
||||||
### API Endpoints:
|
|
||||||
1. **List**: Retrieve a paginated list of files.
|
|
||||||
Example: GET /files/?page=2
|
|
||||||
2. **Retrieve**: Get a specific file by its ID.
|
|
||||||
Example: GET /files/{id}/
|
|
||||||
3. **Create**: Create a new file.
|
|
||||||
Example: POST /files/
|
|
||||||
4. **Update**: Update a file by its ID.
|
|
||||||
Example: PUT /files/{id}/
|
|
||||||
5. **Delete**: Soft delete a file by its ID.
|
|
||||||
Example: DELETE /files/{id}/
|
|
||||||
|
|
||||||
|
|
||||||
### Ordering: created_at, updated_at, title
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Ascending: GET /api/v1.0/files/?ordering=created_at
|
|
||||||
|
|
||||||
### Filtering:
|
|
||||||
- `is_creator_me=true`: Returns files created by the current user.
|
|
||||||
- `is_creator_me=false`: Returns files created by other users.
|
|
||||||
- `is_deleted=false`: Returns files that are not (soft) deleted
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- GET /api/v1.0/files/?is_creator_me=true
|
|
||||||
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
|
|
||||||
|
|
||||||
### Notes:
|
|
||||||
- Implements soft delete logic to retain file
|
|
||||||
"""
|
|
||||||
|
|
||||||
ordering = ["-updated_at"]
|
|
||||||
ordering_fields = ["created_at", "updated_at", "title"]
|
|
||||||
pagination_class = Pagination
|
|
||||||
permission_classes = [
|
|
||||||
permissions.FilePermission,
|
|
||||||
]
|
|
||||||
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
|
|
||||||
default_serializer_class = serializers.FileSerializer
|
|
||||||
serializer_classes = {
|
|
||||||
"list": serializers.ListFileSerializer,
|
|
||||||
"create": serializers.CreateFileSerializer,
|
|
||||||
}
|
|
||||||
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
|
|
||||||
filterset_class = ListFileFilter
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
"""Get queryset that defaults to the current request user."""
|
|
||||||
user = self.request.user
|
|
||||||
queryset = super().get_queryset().select_related("creator")
|
|
||||||
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return queryset.none()
|
|
||||||
|
|
||||||
# For now, we force the filtering on the current user in all cases, might evolve later
|
|
||||||
queryset = queryset.filter(creator=user)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def get_response_for_queryset(self, queryset, context=None):
|
|
||||||
"""Return paginated response for the queryset if requested."""
|
|
||||||
context = context or self.get_serializer_context()
|
|
||||||
page = self.paginate_queryset(queryset)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True, context=context)
|
|
||||||
result = self.get_paginated_response(serializer.data)
|
|
||||||
return result
|
|
||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True, context=context)
|
|
||||||
return drf_response.Response(serializer.data)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
"""Set the current user as creator of the newly created file."""
|
|
||||||
|
|
||||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
|
||||||
file_type = serializer.validated_data["type"]
|
|
||||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
|
|
||||||
|
|
||||||
count = models.File.objects.filter(
|
|
||||||
creator=self.request.user,
|
|
||||||
deleted_at__isnull=True,
|
|
||||||
type=file_type,
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if count >= config_for_file_type["max_count_by_user"]:
|
|
||||||
logger.info(
|
|
||||||
"create_item: user reached max files per user for type %s",
|
|
||||||
file_type,
|
|
||||||
)
|
|
||||||
raise serializers.PermissionDenied(
|
|
||||||
_("You have reached the maximum number of files for this type.")
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer.save(creator=self.request.user)
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
|
||||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
|
||||||
instance.soft_delete()
|
|
||||||
|
|
||||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
|
||||||
@FeatureFlag.require("file_upload")
|
|
||||||
def upload_ended(self, request, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Check the actual uploaded file and mark it as ready.
|
|
||||||
"""
|
|
||||||
|
|
||||||
file = self.get_object()
|
|
||||||
|
|
||||||
if not file.is_pending_upload:
|
|
||||||
raise drf_exceptions.ValidationError(
|
|
||||||
{"file": "This action is only available for files in PENDING state."},
|
|
||||||
code="file_upload_state_not_pending",
|
|
||||||
)
|
|
||||||
|
|
||||||
s3_client = default_storage.connection.meta.client
|
|
||||||
|
|
||||||
head_response = s3_client.head_object(
|
|
||||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
|
||||||
)
|
|
||||||
file_size = head_response["ContentLength"]
|
|
||||||
|
|
||||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
|
||||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
|
||||||
if file_size > config_for_file_type["max_size"]:
|
|
||||||
self._complete_file_deletion(file)
|
|
||||||
logger.info(
|
|
||||||
"upload_ended: file size (%s) for file %s higher than the allowed max size",
|
|
||||||
file_size,
|
|
||||||
file.file_key,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.ValidationError(
|
|
||||||
detail="The file size is higher than the allowed max size.",
|
|
||||||
code="file_size_exceeded",
|
|
||||||
)
|
|
||||||
|
|
||||||
# python-magic recommends using at least the first 2048 bytes
|
|
||||||
# to reduce incorrect identification.
|
|
||||||
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
|
|
||||||
# of the file for mime type identification.
|
|
||||||
if file_size > 2048:
|
|
||||||
range_response = s3_client.get_object(
|
|
||||||
Bucket=default_storage.bucket_name,
|
|
||||||
Key=file.file_key,
|
|
||||||
Range="bytes=0-2047",
|
|
||||||
)
|
|
||||||
file_head = range_response["Body"].read()
|
|
||||||
else:
|
|
||||||
file_head = s3_client.get_object(
|
|
||||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
|
||||||
)["Body"].read()
|
|
||||||
|
|
||||||
# Use improved MIME type detection combining magic bytes and file extension
|
|
||||||
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
|
|
||||||
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
|
|
||||||
|
|
||||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
|
||||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
|
||||||
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
|
|
||||||
if mimetype not in allowed_file_mimetypes:
|
|
||||||
self._complete_file_deletion(file)
|
|
||||||
logger.warning(
|
|
||||||
"upload_ended: mimetype not allowed %s for file %s",
|
|
||||||
mimetype,
|
|
||||||
file.file_key,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.ValidationError(
|
|
||||||
detail="The file type is not allowed.",
|
|
||||||
code="file_type_not_allowed",
|
|
||||||
)
|
|
||||||
|
|
||||||
file.upload_state = models.FileUploadStateChoices.READY
|
|
||||||
file.mimetype = mimetype
|
|
||||||
file.size = file_size
|
|
||||||
|
|
||||||
file.save(update_fields=["upload_state", "mimetype", "size"])
|
|
||||||
|
|
||||||
if head_response["ContentType"] != mimetype:
|
|
||||||
logger.info(
|
|
||||||
"upload_ended: content type mismatch between object storage and file,"
|
|
||||||
" updating from %s to %s",
|
|
||||||
head_response["ContentType"],
|
|
||||||
mimetype,
|
|
||||||
)
|
|
||||||
s3_client.copy_object(
|
|
||||||
Bucket=default_storage.bucket_name,
|
|
||||||
Key=file.file_key,
|
|
||||||
CopySource={
|
|
||||||
"Bucket": default_storage.bucket_name,
|
|
||||||
"Key": file.file_key,
|
|
||||||
},
|
|
||||||
ContentType=mimetype,
|
|
||||||
Metadata=head_response["Metadata"],
|
|
||||||
MetadataDirective="REPLACE",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Not yet implemented
|
|
||||||
# Change the file.upload_state when this will be done
|
|
||||||
# malware_detection.analyse_file(file.file_key, file_id=file.id)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(file)
|
|
||||||
|
|
||||||
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def _complete_file_deletion(self, file):
|
|
||||||
"""Delete a file completely."""
|
|
||||||
file.soft_delete()
|
|
||||||
file.hard_delete()
|
|
||||||
process_file_deletion.delay(file.id)
|
|
||||||
|
|
||||||
def _authorize_subrequest(self, request, pattern):
|
|
||||||
"""
|
|
||||||
Authorize access based on the original URL of an Nginx subrequest
|
|
||||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
|
||||||
|
|
||||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
|
||||||
is configured to do this.
|
|
||||||
|
|
||||||
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
|
||||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
|
||||||
a 403 error). Note that we return 403 errors without any further details for security
|
|
||||||
reasons.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- pattern: The regex pattern to extract identifiers from the URL.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- A dictionary of URL parameters if the request is authorized.
|
|
||||||
Raises:
|
|
||||||
- PermissionDenied if authorization fails.
|
|
||||||
"""
|
|
||||||
# Extract the original URL from the request header
|
|
||||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
|
||||||
if not original_url:
|
|
||||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
parsed_url = urlparse(original_url)
|
|
||||||
match = pattern.search(unquote(parsed_url.path))
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
logger.warning(
|
|
||||||
"Subrequest URL '%s' did not match pattern '%s'",
|
|
||||||
parsed_url.path,
|
|
||||||
pattern,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
try:
|
|
||||||
url_params = match.groupdict()
|
|
||||||
except (ValueError, AttributeError) as exc:
|
|
||||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
|
||||||
raise drf_exceptions.PermissionDenied() from exc
|
|
||||||
|
|
||||||
pk = url_params.get("pk")
|
|
||||||
if not pk:
|
|
||||||
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
# Fetch the file and check if the user has access
|
|
||||||
queryset = models.File.objects.all()
|
|
||||||
# No suspicious analysis implemented yet
|
|
||||||
# queryset = self._filter_suspicious_files(queryset, request.user)
|
|
||||||
try:
|
|
||||||
file = queryset.get(pk=pk)
|
|
||||||
except models.File.DoesNotExist as exc:
|
|
||||||
logger.warning("File with ID '%s' does not exist", pk)
|
|
||||||
raise drf_exceptions.PermissionDenied() from exc
|
|
||||||
|
|
||||||
user_abilities = file.get_abilities(request.user)
|
|
||||||
if not user_abilities.get(self.action, False):
|
|
||||||
logger.warning(
|
|
||||||
"User '%s' lacks permission for file '%s'", request.user.id, pk
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Subrequest authorization successful. Extracted parameters: %s", url_params
|
|
||||||
)
|
|
||||||
return url_params, request.user.id, file
|
|
||||||
|
|
||||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
|
||||||
@FeatureFlag.require("file_upload")
|
|
||||||
def media_auth(self, request, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
This view is used by an Nginx subrequest to control access to an file's
|
|
||||||
attachment file.
|
|
||||||
|
|
||||||
When we let the request go through, we compute authorization headers that will be added to
|
|
||||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
|
||||||
annotation. The request will then be proxied to the object storage backend who will
|
|
||||||
respond with the file after checking the signature included in headers.
|
|
||||||
"""
|
|
||||||
url_params, _, file = self._authorize_subrequest(
|
|
||||||
request, MEDIA_STORAGE_URL_PATTERN
|
|
||||||
)
|
|
||||||
|
|
||||||
if file.is_pending_upload:
|
|
||||||
logger.warning("File '%s' is not ready", file.id)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
# Generate S3 authorization headers using the extracted URL parameters
|
|
||||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
|
||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
|
||||||
|
|||||||
@@ -14,19 +14,10 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
auth_header = request.headers.get("Authorization")
|
token = request.data.get("token")
|
||||||
|
if not token:
|
||||||
if not auth_header:
|
|
||||||
return None # No authentication attempted
|
return None # No authentication attempted
|
||||||
|
|
||||||
parts = auth_header.split()
|
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
||||||
raise exceptions.AuthenticationFailed(
|
|
||||||
"Authorization header must be: Bearer <token>"
|
|
||||||
)
|
|
||||||
|
|
||||||
token = parts[1]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
verifier = TokenVerifier(
|
verifier = TokenVerifier(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
|
|||||||
@@ -14,15 +14,9 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||||
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
|
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
||||||
)
|
)
|
||||||
|
|
||||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
|
||||||
f"{settings.MEDIA_URL:s}"
|
|
||||||
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||||
# the choice of languages which should not be limited to the few languages active in
|
# the choice of languages which should not be limited to the few languages active in
|
||||||
# the app.
|
# the app.
|
||||||
|
|||||||
@@ -1,65 +1,27 @@
|
|||||||
"""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
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
import jwt as pyJwt
|
||||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
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.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 BaseJWTAuthentication(authentication.BaseAuthentication):
|
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||||
"""Base JWT authentication class."""
|
"""JWT authentication for application-delegated API access.
|
||||||
|
|
||||||
def __init__(
|
Validates JWT tokens issued to applications that are acting on behalf
|
||||||
self,
|
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||||
secret_key,
|
"""
|
||||||
algorithm,
|
|
||||||
issuer,
|
|
||||||
audience,
|
|
||||||
expiration_seconds,
|
|
||||||
token_type,
|
|
||||||
is_enabled,
|
|
||||||
):
|
|
||||||
"""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)
|
|
||||||
is_enabled: Whether this authentication backend is active
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self.is_enabled = is_enabled
|
|
||||||
self._token_service = None
|
|
||||||
|
|
||||||
if not self.is_enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -68,9 +30,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
Tuple of (user, payload) if authentication successful, None otherwise
|
Tuple of (user, payload) if authentication successful, None otherwise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.is_enabled:
|
|
||||||
return None
|
|
||||||
|
|
||||||
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":
|
||||||
@@ -89,78 +48,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
|
|
||||||
return self.authenticate_credentials(token)
|
return self.authenticate_credentials(token)
|
||||||
|
|
||||||
def decode_jwt(self, token):
|
|
||||||
"""Decode and validate JWT token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: JWT token string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Decoded payload dict, or None if token is invalid
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthenticationFailed: If token is expired or has invalid issuer/audience
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = self._token_service.decode_jwt(token)
|
|
||||||
return payload
|
|
||||||
except jwt_token.TokenExpiredError as e:
|
|
||||||
logger.warning("Token expired")
|
|
||||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
|
||||||
except jwt_token.TokenInvalidError as e:
|
|
||||||
logger.warning("Invalid JWT issuer or audience: %s", 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")
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
logger.warning("Missing 'user_id' in JWT payload")
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(id=user_id)
|
|
||||||
except User.DoesNotExist as e:
|
|
||||||
logger.warning("User not found: %s", user_id)
|
|
||||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
|
||||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
|
||||||
"""Return authentication scheme for WWW-Authenticate header."""
|
|
||||||
return "Bearer"
|
|
||||||
|
|
||||||
def authenticate_credentials(self, token):
|
def authenticate_credentials(self, token):
|
||||||
"""Validate JWT token and return authenticated user.
|
"""Validate JWT token and return authenticated user.
|
||||||
|
|
||||||
@@ -175,42 +62,36 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
Raises:
|
Raises:
|
||||||
AuthenticationFailed: If token is expired, or user not found
|
AuthenticationFailed: If token is expired, or user not found
|
||||||
"""
|
"""
|
||||||
|
# Decode and validate JWT
|
||||||
payload = self.decode_jwt(token)
|
try:
|
||||||
|
payload = pyJwt.decode(
|
||||||
if payload is None:
|
token,
|
||||||
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||||
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
)
|
||||||
|
except pyJwt.ExpiredSignatureError as e:
|
||||||
|
logger.warning("Token expired")
|
||||||
|
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||||
|
except pyJwt.InvalidIssuerError as e:
|
||||||
|
logger.warning("Invalid JWT issuer: %s", e)
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
|
except pyJwt.InvalidAudienceError as e:
|
||||||
|
logger.warning("Invalid JWT audience: %s", e)
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
|
except pyJwt.InvalidTokenError:
|
||||||
|
# Invalid JWT token - defer to next authentication backend
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.validate_payload(payload)
|
user_id = payload.get("user_id")
|
||||||
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,
|
|
||||||
is_enabled=settings.APPLICATION_ENABLED,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_payload(self, payload):
|
|
||||||
"""Validate application-specific claims."""
|
|
||||||
client_id = payload.get("client_id")
|
client_id = payload.get("client_id")
|
||||||
is_delegated = payload.get("delegated", False)
|
is_delegated = payload.get("delegated", False)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
logger.warning("Missing 'user_id' in JWT payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
if not client_id:
|
if not client_id:
|
||||||
logger.warning("Missing 'client_id' in JWT payload")
|
logger.warning("Missing 'client_id' in JWT payload")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
@@ -221,7 +102,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
|||||||
logger.warning("Application not found: %s", client_id)
|
logger.warning("Application not found: %s", client_id)
|
||||||
raise exceptions.AuthenticationFailed("Application not found.") from e
|
raise exceptions.AuthenticationFailed("Application not found.") from e
|
||||||
|
|
||||||
if not application.is_active:
|
if not application.active:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Inactive application attempted authentication: %s", client_id
|
"Inactive application attempted authentication: %s", client_id
|
||||||
)
|
)
|
||||||
@@ -231,25 +112,21 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
|||||||
logger.warning("Token is not marked as delegated")
|
logger.warning("Token is not marked as delegated")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist as e:
|
||||||
|
logger.warning("User not found: %s", user_id)
|
||||||
|
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||||
|
|
||||||
class AddonsJWTAuthentication(BaseJWTAuthentication):
|
if not user.is_active:
|
||||||
"""JWT authentication for addons API access.
|
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||||
|
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||||
|
|
||||||
Validates JWT tokens issued to addons.
|
return (user, payload)
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def authenticate_header(self, request):
|
||||||
"""Initialize authentication backend with addons JWT settings from Django settings."""
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
|
return "Bearer"
|
||||||
super().__init__(
|
|
||||||
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
|
||||||
issuer=settings.ADDONS_TOKEN_ISSUER,
|
|
||||||
audience=settings.ADDONS_TOKEN_AUDIENCE,
|
|
||||||
expiration_seconds=settings.ADDONS_TOKEN_TTL,
|
|
||||||
token_type=settings.ADDONS_TOKEN_TYPE,
|
|
||||||
is_enabled=settings.ADDONS_ENABLED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceServerBackend(LaSuiteBackend):
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -7,6 +8,7 @@ from django.contrib.auth.hashers import check_password
|
|||||||
from django.core.exceptions import SuspiciousOperation, 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 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 (
|
||||||
@@ -20,8 +22,6 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core import api, models
|
from core import api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
|
||||||
from core.services.jwt_token import JwtTokenService
|
|
||||||
|
|
||||||
from . import authentication, permissions, serializers
|
from . import authentication, permissions, serializers
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
url_path="token",
|
url_path="token",
|
||||||
url_name="token",
|
url_name="token",
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("application")
|
|
||||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||||
"""Generate JWT access token for application delegation.
|
"""Generate JWT access token for application delegation.
|
||||||
|
|
||||||
@@ -63,12 +62,12 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
except models.Application.DoesNotExist as e:
|
except models.Application.DoesNotExist as e:
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
||||||
|
|
||||||
|
if not application.active:
|
||||||
|
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
||||||
|
|
||||||
if not check_password(client_secret, application.client_secret):
|
if not check_password(client_secret, application.client_secret):
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||||
|
|
||||||
if not application.is_active:
|
|
||||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
|
||||||
|
|
||||||
email = serializer.validated_data["scope"]
|
email = serializer.validated_data["scope"]
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
@@ -129,28 +128,33 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
"Multiple user accounts share a common email."
|
"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 [])
|
||||||
|
|
||||||
token_service = JwtTokenService(
|
payload = {
|
||||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
"iat": now,
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
"client_id": client_id,
|
||||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
"scope": scope,
|
||||||
)
|
"user_id": str(user.id),
|
||||||
|
"delegated": True,
|
||||||
|
}
|
||||||
|
|
||||||
data = token_service.generate_jwt(
|
token = jwt.encode(
|
||||||
user,
|
payload,
|
||||||
scope,
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
{
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
"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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,7 +179,6 @@ class RoomViewSet(
|
|||||||
|
|
||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
authentication.AddonsJWTAuthentication,
|
|
||||||
ResourceServerAuthentication,
|
ResourceServerAuthentication,
|
||||||
]
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.core.files.storage import default_storage
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
import factory.fuzzy
|
import factory.fuzzy
|
||||||
@@ -129,7 +126,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
|
|||||||
model = models.Application
|
model = models.Application
|
||||||
|
|
||||||
name = factory.Faker("company")
|
name = factory.Faker("company")
|
||||||
is_active = True
|
active = True
|
||||||
client_id = factory.LazyFunction(utils.generate_client_id)
|
client_id = factory.LazyFunction(utils.generate_client_id)
|
||||||
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
||||||
scopes = []
|
scopes = []
|
||||||
@@ -156,42 +153,3 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
domain = factory.Faker("domain_name")
|
domain = factory.Faker("domain_name")
|
||||||
application = factory.SubFactory(ApplicationFactory)
|
application = factory.SubFactory(ApplicationFactory)
|
||||||
|
|
||||||
|
|
||||||
class FileFactory(factory.django.DjangoModelFactory):
|
|
||||||
"""A factory to create files"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.File
|
|
||||||
skip_postgeneration_save = True
|
|
||||||
|
|
||||||
title = factory.Sequence(lambda n: f"file{n}")
|
|
||||||
creator = factory.SubFactory(UserFactory)
|
|
||||||
deleted_at = None
|
|
||||||
type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices])
|
|
||||||
filename = factory.lazy_attribute(lambda o: fake.file_name())
|
|
||||||
upload_state = None
|
|
||||||
size = None
|
|
||||||
|
|
||||||
@factory.post_generation
|
|
||||||
def update_upload_state(self, create, extracted, **kwargs):
|
|
||||||
"""Change the upload state of a file."""
|
|
||||||
if create and extracted:
|
|
||||||
self.upload_state = extracted
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
@factory.post_generation
|
|
||||||
def upload_bytes(self, create, extracted, **kwargs):
|
|
||||||
"""Save content of the file into the storage"""
|
|
||||||
if create and extracted is not None:
|
|
||||||
content = (
|
|
||||||
extracted
|
|
||||||
if isinstance(extracted, bytes)
|
|
||||||
else str(extracted).encode("utf-8")
|
|
||||||
)
|
|
||||||
|
|
||||||
self.filename = kwargs.get("filename", self.filename or "content.txt")
|
|
||||||
self.size = len(content)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
default_storage.save(self.file_key, BytesIO(content))
|
|
||||||
|
|||||||