Compare commits
86 Commits
encryption
...
csp-addin
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e78cc3dab | |||
| 1af58b889e | |||
| cf4e347589 | |||
| fc260b2686 | |||
| cd7799997e | |||
| a2bccf4f4f | |||
| 6830250f2c | |||
| 0c0ce87947 | |||
| 597eba6e8a | |||
| 47dbc271ba | |||
| c3adcc8ff3 | |||
| 07698ddced | |||
| cce0a45fbc | |||
| da1767c261 | |||
| dd3d47afe6 | |||
| ac2eddc10f | |||
| 35863ec3b6 | |||
| 3d125e940f | |||
| 6f38d60a27 | |||
| 012857f8c6 | |||
| e5a804f748 | |||
| 6bb8084aa0 | |||
| 4548f69de8 | |||
| 1a3a92f901 | |||
| 181b97b310 | |||
| bb816eb2e5 | |||
| 6ee89b201e | |||
| ecb710688d | |||
| 5e1e05b001 | |||
| 211e97edfa | |||
| c4fc46727c | |||
| 28acbb5459 | |||
| 5a81e2b92c | |||
| df24aaab71 | |||
| 3b474ba1c0 | |||
| fc4b6d679a | |||
| 73dd684c8d | |||
| 8507cdd2b6 | |||
| aaf21e97e8 | |||
| bd3a26a2af | |||
| 4d222e4ab4 | |||
| b80c46da54 | |||
| 451be40bb7 | |||
| 45c5a443fb | |||
| 34f9dea73f | |||
| f0fda145d9 | |||
| d12ced352a | |||
| 497b45f2ca | |||
| 52fbd56666 | |||
| 170763a1f7 | |||
| 037166fb21 | |||
| 6374e136d8 | |||
| 3ccb2d4dd8 | |||
| 5d7a54e809 | |||
| 07af7a85ff | |||
| 6180ac4e4f | |||
| a30b573d36 | |||
| 83b95c5520 | |||
| 0c9b83c793 | |||
| 812d80c4f2 | |||
| 34212be6e2 | |||
| d8ccd02bb2 | |||
| 08aa63ecb2 | |||
| bbc8f61221 | |||
| 6b656eefd7 | |||
| 264f267ac3 | |||
| 4bf3ba4c48 | |||
| 4fdc2eee11 | |||
| 19c2a378e7 | |||
| 5a70604f01 | |||
| 7e422e5846 | |||
| d915b93caa | |||
| 9d9ec794aa | |||
| 570d57d5f5 | |||
| 7469ccfdf1 | |||
| 0d3bd2727e | |||
| 660b022eb1 | |||
| a28b611ecc | |||
| dfa6092c72 | |||
| 7d8c166c7f | |||
| f7dd1f8fd7 | |||
| 416411b843 | |||
| 45e0665cf0 | |||
| a3eabf8f66 | |||
| 7c81947681 | |||
| 2424817523 |
@@ -223,8 +223,6 @@ jobs:
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
@@ -306,7 +304,8 @@ jobs:
|
||||
working-directory: src/summary
|
||||
|
||||
env:
|
||||
APP_API_TOKEN: "test-api-token"
|
||||
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"
|
||||
@@ -318,8 +317,6 @@ jobs:
|
||||
LLM_BASE_URL: "https://configure-your-url.com"
|
||||
LLM_API_KEY: "test-llm-secret"
|
||||
LLM_MODEL: "test-llm-model"
|
||||
WEBHOOK_API_TOKEN: "test-webhook-secret"
|
||||
WEBHOOK_URL: "https://configure-your-url.com"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -8,9 +8,74 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒️(backend) add validation of Room.configuration
|
||||
- ✨(helm) add support multiple transcribe worker / endpoint #1247
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♻(frontend) standardize role terminology across localizations
|
||||
- 🐛(backend) make start-recording atomic and fault-tolerant
|
||||
- 🔒️(frontend) room ids are generated with non-cryptographic rand
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@@ -73,7 +73,9 @@ create-env-files: \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
env.d/development/summary
|
||||
env.d/development/summary \
|
||||
env.d/development/kube-secret \
|
||||
env.d/development/multi_user_transcriber
|
||||
.PHONY: create-env-files
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
@@ -94,6 +96,7 @@ bootstrap: \
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend
|
||||
@$(MAKE) build-frontend
|
||||
@$(MAKE) build-agents
|
||||
.PHONY: build
|
||||
|
||||
build-backend: ## build the app-dev container
|
||||
@@ -105,6 +108,10 @@ build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend
|
||||
.PHONY: build-frontend
|
||||
|
||||
build-agents: ## build the multi-user-transcriber agent container
|
||||
@$(COMPOSE) build multi-user-transcriber
|
||||
.PHONY: build-agents
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
@@ -125,10 +132,24 @@ run-summary: ## start only the summary application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||
.PHONY: run-summary
|
||||
|
||||
run-agents: ## start the multi-user-transcriber agent
|
||||
@$(MAKE) run-agent-multi-user-transcriber
|
||||
@$(MAKE) run-agent-metadata-collector
|
||||
.PHONY: run-agents
|
||||
|
||||
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
|
||||
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
||||
.PHONY: run-agent-multi-user-transcriber
|
||||
|
||||
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
|
||||
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
|
||||
.PHONY: run-agent-metadata-collector
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-agents
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
@@ -265,6 +286,12 @@ env.d/development/kc_postgresql:
|
||||
env.d/development/summary:
|
||||
cp -n env.d/development/summary.dist env.d/development/summary
|
||||
|
||||
env.d/development/kube-secret:
|
||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
||||
|
||||
env.d/development/multi_user_transcriber:
|
||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
@@ -352,15 +379,9 @@ frontend-i18n-generate: \
|
||||
|
||||
# -- K8S
|
||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
build-k8s-cluster: \
|
||||
env.d/development/kube-secret \
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
|
||||
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||
|
||||
if DEV_ENV == 'dev-dinum':
|
||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||
@@ -34,10 +34,11 @@ docker_build(
|
||||
'localhost:5001/meet-frontend-dinum:latest',
|
||||
context='..',
|
||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
only=['./src/frontend', './src/addons', './docker', './.dockerignore'],
|
||||
target = 'frontend-production',
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
sync('../src/addons', '/home/addons'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend-dinum')
|
||||
@@ -95,13 +96,20 @@ docker_build(
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
load('ext://secret', 'secret_yaml_generic')
|
||||
k8s_yaml(secret_yaml_generic(
|
||||
name="secret-dev",
|
||||
from_env_file="../env.d/development/kube-secret"
|
||||
))
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
|
||||
@@ -101,6 +101,12 @@ update_npm_version "mail"
|
||||
# Update backend pyproject.toml
|
||||
update_python_version "backend"
|
||||
|
||||
# Run uv lock in backend
|
||||
print_info "Running uv lock in backend..."
|
||||
cd "src/backend"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update summary pyproject.toml
|
||||
update_python_version "summary"
|
||||
|
||||
@@ -149,6 +155,7 @@ echo " - src/frontend/package.json"
|
||||
echo " - src/sdk/package.json"
|
||||
echo " - src/mail/package.json"
|
||||
echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - CHANGELOG.md"
|
||||
|
||||
@@ -246,6 +246,29 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
- LIVEKIT_API_KEY=devkey
|
||||
- LIVEKIT_API_SECRET=secret
|
||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
||||
- AWS_S3_ACCESS_KEY_ID=meet
|
||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ./src/agents
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
@@ -307,6 +330,14 @@ services:
|
||||
- action: rebuild
|
||||
path: ./src/summary
|
||||
|
||||
multi-user-transcriber:
|
||||
build:
|
||||
context: ./src/agents
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
|
||||
@@ -38,16 +38,32 @@ COPY ./docker/dinum-frontend/assets/ \
|
||||
COPY ./docker/dinum-frontend/fonts/ \
|
||||
./dist/assets/fonts/
|
||||
|
||||
# ---- Addons builder image ----
|
||||
FROM node:20-alpine AS addons-builder
|
||||
|
||||
WORKDIR /home/addons/outlook
|
||||
|
||||
COPY ./src/addons/outlook/package.json ./package.json
|
||||
COPY ./src/addons/outlook/package-lock.json ./package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY ./src/addons/outlook/ .
|
||||
|
||||
RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 \
|
||||
libcrypto3 \
|
||||
libxml2>=2.12.7-r2 \
|
||||
libxslt>=1.1.39-r2 \
|
||||
libexpat>=2.7.2-r0 \
|
||||
libpng>=1.6.53-r0
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
&& apk del curl
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -59,7 +75,11 @@ COPY --from=meet-builder \
|
||||
/home/frontend/dist \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||
COPY --from=addons-builder \
|
||||
/home/addons/outlook/dist \
|
||||
/usr/share/nginx/html/addons/outlook
|
||||
|
||||
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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 https://*.cloud.microsoft";
|
||||
set $ministries_domains "https://courrieljf.ccomptes.fr";
|
||||
|
||||
set $nonce $request_id;
|
||||
|
||||
set $csp "upgrade-insecure-requests; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains} ${ministries_domains}; ";
|
||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
||||
set $csp "${csp}connect-src 'self' ${ms_domains} ${ministries_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;
|
||||
}
|
||||
@@ -71,6 +71,9 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
WHISPERX_BASE_URL=https://configure-your-url.com
|
||||
WHISPERX_API_KEY=<key>
|
||||
LLM_BASE_URL=https://configure-your-url.com
|
||||
LLM_API_KEY=<key>
|
||||
@@ -0,0 +1,9 @@
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
STT_PROVIDER=kyutai
|
||||
ENABLE_SILERO_VAD=False
|
||||
|
||||
KYUTAI_STT_BASE_URL=
|
||||
KYUTAI_API_KEY=
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": [
|
||||
"office-addins"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:office-addins/recommended"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 396 B |
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": false
|
||||
}
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||
<Version>0.0.1.0</Version>
|
||||
<ProviderName>__APP_NAME__</ProviderName>
|
||||
<DefaultLocale>fr-FR</DefaultLocale>
|
||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||
<Description DefaultValue="Ajoutez facilement un lien de réunion __APP_NAME__ à vos emails et événements Outlook."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
|
||||
<SupportUrl DefaultValue="https://lasuite.crisp.help/fr/category/visio-15sakkg/"/>
|
||||
<AppDomains>
|
||||
<AppDomain>https://localhost:3000/</AppDomain>
|
||||
</AppDomains>
|
||||
<Hosts>
|
||||
<Host Name="Mailbox"/>
|
||||
</Hosts>
|
||||
<Requirements>
|
||||
<Sets>
|
||||
<Set Name="Mailbox" MinVersion="1.1"/>
|
||||
</Sets>
|
||||
</Requirements>
|
||||
<FormSettings>
|
||||
<Form xsi:type="ItemRead">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
<RequestedHeight>250</RequestedHeight>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
<Form xsi:type="ItemEdit">
|
||||
<DesktopSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</DesktopSettings>
|
||||
</Form>
|
||||
</FormSettings>
|
||||
<Permissions>ReadWriteItem</Permissions>
|
||||
<Rule xsi:type="RuleCollection" Mode="Or">
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
||||
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
|
||||
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
|
||||
</Rule>
|
||||
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||
<Requirements>
|
||||
<bt:Sets DefaultMinVersion="1.3">
|
||||
<bt:Set Name="Mailbox"/>
|
||||
</bt:Sets>
|
||||
</Requirements>
|
||||
<Hosts>
|
||||
<Host xsi:type="MailHost">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Commands.Url"/>
|
||||
|
||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgReadGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="apptComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||
<Label resid="OpenSettings.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="OpenSettings.Label"/>
|
||||
<Description resid="OpenSettings.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/assets/settings-16.png"/>
|
||||
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/assets/settings-32.png"/>
|
||||
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/assets/settings-80.png"/>
|
||||
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/assets/add-16.png"/>
|
||||
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/assets/add-32.png"/>
|
||||
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/assets/add-80.png"/>
|
||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
|
||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
|
||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
|
||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "office-addin-taskpane-js",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/suitenumerique/meet.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"config": {
|
||||
"app_to_debug": "outlook",
|
||||
"app_type_to_debug": "desktop",
|
||||
"dev_server_port": 3000
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack --mode development",
|
||||
"dev-server": "webpack serve --mode development",
|
||||
"lint": "office-addin-lint check",
|
||||
"lint:fix": "office-addin-lint fix",
|
||||
"prettier": "office-addin-lint prettier",
|
||||
"signin": "office-addin-dev-settings m365-account login",
|
||||
"signout": "office-addin-dev-settings m365-account logout",
|
||||
"start": "office-addin-debugging start manifest.xml",
|
||||
"stop": "office-addin-debugging stop manifest.xml",
|
||||
"validate": "office-addin-manifest validate manifest.xml",
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.36.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"office-addin-cli": "^2.0.3",
|
||||
"office-addin-debugging": "^6.0.3",
|
||||
"office-addin-dev-certs": "^2.0.3",
|
||||
"office-addin-lint": "^3.0.3",
|
||||
"office-addin-manifest": "^2.0.3",
|
||||
"office-addin-prettier-config": "^2.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.2.1"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title data-app-name></title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
/* global Office */
|
||||
const { createRoom, initSession } = require("../common/api");
|
||||
const { startPolling } = require("../common/polling");
|
||||
const { saveSession, loadSession } = require("../common/session");
|
||||
const { openTransitDialog } = require("../common/transitDialog");
|
||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
|
||||
Office.onReady(function (info) {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
}
|
||||
});
|
||||
|
||||
function notify(message) {
|
||||
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
||||
message,
|
||||
persistent: false,
|
||||
icon: "Icon.16x16",
|
||||
});
|
||||
}
|
||||
|
||||
function insertMeetingLink(event, session) {
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de lecture : ${getResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const newBody = getResult.value + message;
|
||||
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur d'insertion : ${setResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
notify("Lien de réunion inséré !");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de localisation : ${locationResult.error.message}`);
|
||||
} else {
|
||||
notify("Lien de réunion inséré !");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(`Erreur : ${err.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
function connect(event) {
|
||||
initSession()
|
||||
.then((data) => {
|
||||
const stopPolling = startPolling(data.csrf_token, {
|
||||
onSuccess: (sessionData) => {
|
||||
saveSession(sessionData).then(() => {
|
||||
insertMeetingLink(event, sessionData);
|
||||
});
|
||||
},
|
||||
onTimeout: () => {
|
||||
notify("Connexion expirée, veuillez réessayer.");
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
notify("Une erreur est survenue, veuillez ré-essayer");
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
openTransitDialog(data.transit_token, {
|
||||
onCancel: () => {
|
||||
stopPolling();
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
stopPolling();
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(`Erreur : ${err.message}`);
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
function generateMeetingLink(event) {
|
||||
const session = loadSession();
|
||||
if (session?.access_token) {
|
||||
insertMeetingLink(event, session);
|
||||
} else {
|
||||
connect(event);
|
||||
}
|
||||
}
|
||||
|
||||
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLink);
|
||||
Office.actions.associate("generateMeetingLinkFromMail", generateMeetingLink);
|
||||
@@ -0,0 +1,82 @@
|
||||
const { URLS } = require("./urls");
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
|
||||
.map((cookie) => cookie.split("=")[1])
|
||||
.pop();
|
||||
}
|
||||
|
||||
function authHeaders(session) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds headers for CSRF-protected requests.
|
||||
*
|
||||
* Two CSRF flows coexist in this addon:
|
||||
*
|
||||
* 1. Cookie-based (Django default): used by `exchange`, called from the
|
||||
* OAuth success page in a normal browser context. Django's CSRF
|
||||
* middleware has already set the `csrftoken` cookie via the auth
|
||||
* redirect, so we read it from `document.cookie` and echo it back
|
||||
* as `X-CSRFToken`. The middleware verifies the header matches the
|
||||
* cookie. No `csrfToken` argument needed — `getCsrfToken()` handles it.
|
||||
*
|
||||
* 2. Body-passed token: used by `poll`, called from the Office dialog /
|
||||
* taskpane iframe. Cookie access inside Office iframes is unreliable
|
||||
* across Outlook clients, so we can't depend on `document.cookie`
|
||||
* being populated. Instead, `init` returns the CSRF token in its JSON
|
||||
* response body, and callers pass it explicitly to subsequent calls.
|
||||
* The token still travels as `X-CSRFToken` — only its source differs.
|
||||
*
|
||||
* The `csrfToken` parameter takes precedence when provided; falls back
|
||||
* to the cookie when omitted.
|
||||
*/
|
||||
function csrfHeaders(csrfToken) {
|
||||
const token = csrfToken || getCsrfToken();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { "X-CSRFToken": token }),
|
||||
};
|
||||
}
|
||||
|
||||
async function request(path, { session, csrf, csrfToken, ...opts } = {}) {
|
||||
const headers = {
|
||||
...(session && authHeaders(session)),
|
||||
...(csrf && csrfHeaders(csrfToken)),
|
||||
...opts.headers,
|
||||
};
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: csrf ? "include" : opts.credentials,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initSession: () => request(URLS.init, { method: "POST" }),
|
||||
pollSession: (csrfToken) =>
|
||||
request(URLS.poll, {
|
||||
method: "POST",
|
||||
csrf: true,
|
||||
csrfToken,
|
||||
}),
|
||||
exchangeSession: (transitToken) =>
|
||||
request(URLS.exchange, {
|
||||
method: "POST",
|
||||
csrf: true,
|
||||
body: JSON.stringify({ transit_token: transitToken }),
|
||||
}),
|
||||
createRoom: (session) =>
|
||||
request(URLS.rooms, {
|
||||
method: "POST",
|
||||
session,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const { APP_NAME } = require("./index");
|
||||
|
||||
function isOfficeReady() {
|
||||
return typeof Office !== "undefined" && Office?.context?.roamingSettings != null;
|
||||
}
|
||||
|
||||
function applyAppName() {
|
||||
document.querySelectorAll("[data-app-name]").forEach((el) => {
|
||||
el.textContent = APP_NAME;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isOfficeReady,
|
||||
applyAppName,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
|
||||
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
|
||||
|
||||
module.exports = {
|
||||
BASE_URL,
|
||||
APP_NAME,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
const { APP_NAME } = require("./index");
|
||||
|
||||
function _formatPin(pin) {
|
||||
if (!pin) return "";
|
||||
const clean = String(pin).replace(/\s+/g, "");
|
||||
if (!clean) return "";
|
||||
if (/^\d{10}$/.test(clean)) {
|
||||
return clean.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#";
|
||||
}
|
||||
return clean + "#";
|
||||
}
|
||||
|
||||
// todo - support international format
|
||||
function _formatPhone(phone) {
|
||||
if (!phone) return "";
|
||||
const clean = String(phone).replace(/\s+/g, "");
|
||||
if (/^\+33\d{9}$/.test(clean)) {
|
||||
return clean.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
// todo - escape html / link
|
||||
function buildMeetingMessage(data) {
|
||||
if (!data?.url) {
|
||||
throw new Error("buildMeetingMessage: missing url in data");
|
||||
}
|
||||
|
||||
const url = data.url;
|
||||
const phone = _formatPhone(data.telephony?.phone_number);
|
||||
const pin = _formatPin(data.telephony?.pin_code);
|
||||
|
||||
const telephonyBlock =
|
||||
phone && pin
|
||||
? `
|
||||
|
||||
Ou appelez (audio uniquement)
|
||||
(FR) ${phone}
|
||||
Code : ${pin}`
|
||||
: "";
|
||||
|
||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
||||
────────────────────────────────────────
|
||||
Rejoindre la réunion ${APP_NAME}
|
||||
|
||||
<a href="${url}">${url}</a>${telephonyBlock}
|
||||
────────────────────────────────────────</pre>`;
|
||||
|
||||
return { url, message };
|
||||
}
|
||||
|
||||
module.exports = { buildMeetingMessage };
|
||||
@@ -0,0 +1,47 @@
|
||||
const { pollSession } = require("./api");
|
||||
|
||||
const POLLING_INTERVAL_MS = 1000;
|
||||
const POLLING_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const POLLING_MAX_ATTEMPTS = POLLING_TIMEOUT_MS / POLLING_INTERVAL_MS;
|
||||
|
||||
function isPollAuthenticated(sessionData) {
|
||||
return sessionData.state === "authenticated" && sessionData.access_token;
|
||||
}
|
||||
|
||||
function startPolling(csrfToken, { onSuccess, onTimeout, onError }) {
|
||||
let pollCount = 0;
|
||||
let timeoutId = null;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = () => {
|
||||
if (pollCount++ >= POLLING_MAX_ATTEMPTS) {
|
||||
onTimeout?.();
|
||||
return;
|
||||
}
|
||||
|
||||
pollSession(csrfToken)
|
||||
.then((sessionData) => {
|
||||
if (cancelled) return;
|
||||
if (isPollAuthenticated(sessionData)) {
|
||||
onSuccess?.(sessionData);
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(poll, POLLING_INTERVAL_MS);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
onError?.(err);
|
||||
});
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPolling,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
const { isOfficeReady } = require("./helpers");
|
||||
|
||||
const SESSION_KEY = "meetSession";
|
||||
|
||||
// DEV NOTE:
|
||||
// Office.context.roamingSettings persists data in the user's mailbox and
|
||||
// synchronizes it via Exchange across all Outlook clients (desktop, web, mobile)
|
||||
// where the user signs in. This means anything stored here (including tokens)
|
||||
// leaves the local device boundary and is replicated across environments.
|
||||
//
|
||||
// Microsoft guidance explicitly advises NOT storing secrets (e.g., OAuth access
|
||||
// tokens, refresh tokens, or other sensitive credentials) in roamingSettings,
|
||||
// as it is not a secure storage mechanism and lacks OS-level protections.
|
||||
//
|
||||
// That said, for the current alpha version we accept this trade-off for simplicity,
|
||||
// with the expectation that a more secure approach (e.g., in-memory tokens) will replace this.
|
||||
function saveSession(data) {
|
||||
if (!isOfficeReady()) {
|
||||
return Promise.reject(new Error("Office not ready"));
|
||||
}
|
||||
|
||||
if (!data || !data.access_token) {
|
||||
return Promise.reject(new Error("Missing access_token"));
|
||||
}
|
||||
|
||||
const expiresInSeconds = Number(data.expires_in);
|
||||
const expiresAt =
|
||||
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
||||
? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
...data,
|
||||
expiresAt,
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const rs = Office.context.roamingSettings;
|
||||
rs.set(SESSION_KEY, payload);
|
||||
rs.saveAsync((result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(result.error?.message || "saveAsync failed"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
if (!isOfficeReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let session = null;
|
||||
try {
|
||||
const stored = Office.context.roamingSettings.get(SESSION_KEY);
|
||||
if (stored) session = JSON.parse(stored);
|
||||
} catch (e) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Fail closed if expiry is missing — backend is expected to send expires_in.
|
||||
if (!session.expiresAt) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresTs = Date.parse(session.expiresAt);
|
||||
if (!Number.isFinite(expiresTs) || Date.now() >= expiresTs) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
if (!isOfficeReady()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const rs = Office.context.roamingSettings;
|
||||
rs.remove(SESSION_KEY);
|
||||
rs.saveAsync((result) => {
|
||||
resolve();
|
||||
});
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
const { URLS } = require("./urls");
|
||||
|
||||
const DIALOG_SIGNALS = {
|
||||
ready: "ready",
|
||||
done: "done",
|
||||
};
|
||||
|
||||
const DIALOG_HEIGHT = 60;
|
||||
const DIALOG_WIDTH = 50;
|
||||
|
||||
function openTransitDialog(transitToken, { onCancel, onError }) {
|
||||
Office.context.ui.displayDialogAsync(
|
||||
URLS.transitDialog,
|
||||
{ height: DIALOG_HEIGHT, width: DIALOG_WIDTH, displayInIframe: false },
|
||||
(asyncResult) => {
|
||||
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
|
||||
onError?.(asyncResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = asyncResult.value;
|
||||
|
||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg) => {
|
||||
if (arg.message === DIALOG_SIGNALS.ready) {
|
||||
dialog.messageChild(transitToken);
|
||||
return;
|
||||
}
|
||||
if (arg.message === DIALOG_SIGNALS.done) {
|
||||
return;
|
||||
}
|
||||
onCancel?.();
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openTransitDialog,
|
||||
DIALOG_SIGNALS,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const TRANSIT_TOKEN_KEY = "transitToken";
|
||||
|
||||
function save(token) {
|
||||
sessionStorage.setItem(TRANSIT_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
function consume() {
|
||||
try {
|
||||
const token = sessionStorage.getItem(TRANSIT_TOKEN_KEY);
|
||||
sessionStorage.removeItem(TRANSIT_TOKEN_KEY);
|
||||
return token;
|
||||
} catch (err) {
|
||||
console.error("Failed to read transit token:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { save, consume };
|
||||
@@ -0,0 +1,15 @@
|
||||
const { BASE_URL } = require("./index");
|
||||
|
||||
const ADDONS_BASE_URL = `${BASE_URL}/api/v1.0/addons/sessions`;
|
||||
|
||||
const URLS = {
|
||||
authenticate: `${BASE_URL}/api/v1.0/authenticate/`,
|
||||
successPage: `${BASE_URL}/addons/outlook/success.html`,
|
||||
transitDialog: `${BASE_URL}/addons/outlook/transit.html`,
|
||||
init: `${ADDONS_BASE_URL}/init/`,
|
||||
poll: `${ADDONS_BASE_URL}/poll/`,
|
||||
exchange: `${ADDONS_BASE_URL}/exchange/`,
|
||||
rooms: `${BASE_URL}/external-api/v1.0/rooms/`,
|
||||
};
|
||||
|
||||
module.exports = { URLS };
|
||||
@@ -0,0 +1,81 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#sideload-msg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.spinner-svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
/* Background arc (light gray ring) */
|
||||
.spinner-track {
|
||||
stroke: #E5E7EB; /* primary.100 equivalent */
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Foreground rotating arc */
|
||||
.spinner-arc {
|
||||
stroke: #000091; /* primary.800 equivalent */
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
/* circumference = 2 * PI * r where r = 11 -> ~69.115 */
|
||||
/* show 30% -> dashoffset = c - 0.3 * c = ~48.38 */
|
||||
stroke-dasharray: 69.115 69.115;
|
||||
stroke-dashoffset: 48.38;
|
||||
transform-origin: center;
|
||||
animation: spinner-rotate 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hourglass fallback for reduced motion */
|
||||
.spinner-fallback {
|
||||
display: none;
|
||||
color: #000091;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner-svg {
|
||||
display: none;
|
||||
}
|
||||
.spinner-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<svg class="spinner-svg"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Background track -->
|
||||
<circle class="spinner-track" cx="14" cy="14" r="11"
|
||||
/>
|
||||
<!-- Rotating arc -->
|
||||
<circle class="spinner-arc" cx="14" cy="14" r="11"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
|
||||
<span class="spinner-fallback" aria-hidden="true">
|
||||
<svg width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="display: block; transform: translateY(1px);"
|
||||
>
|
||||
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { exchangeSession } = require("../common/api");
|
||||
const { consume } = require("../common/transitToken");
|
||||
|
||||
applyAppName();
|
||||
|
||||
const transitToken = consume();
|
||||
|
||||
if (!transitToken) {
|
||||
console.error("Transit token not found in sessionStorage");
|
||||
window.close();
|
||||
} else {
|
||||
exchangeSession(transitToken)
|
||||
.catch((e) => {
|
||||
console.error(`Error occured: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="taskpane.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
|
||||
<div id="app-body">
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="view-loading">
|
||||
<p class="intro-text">Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated -->
|
||||
<div id="view-unauth" style="display:none;">
|
||||
<p class="intro-text">
|
||||
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
|
||||
</p>
|
||||
<hr class="divider" />
|
||||
<button class="proconnect-button" id="btn-connect">
|
||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
||||
</button>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.proconnect.gouv.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
||||
>
|
||||
Qu’est-ce que ProConnect ?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated -->
|
||||
<div id="view-auth" style="display:none;">
|
||||
<div id="btn-container">
|
||||
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
|
||||
<button id="btn-disconnect">Se déconnecter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="version-tag">
|
||||
<span class="version-badge">alpha</span>
|
||||
<span class="version-number">0.0.1</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
const { APP_NAME } = require("../common");
|
||||
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { initSession, createRoom } = require("../common/api");
|
||||
const { startPolling } = require("../common/polling");
|
||||
const { openTransitDialog } = require("../common/transitDialog");
|
||||
const { loadSession, saveSession, clearSession } = require("../common/session");
|
||||
|
||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||
|
||||
// todo - support loading view while polling
|
||||
// todo - support error view
|
||||
function showView(name) {
|
||||
document.getElementById("view-loading").style.display = "none";
|
||||
document.getElementById("view-unauth").style.display = "none";
|
||||
document.getElementById("view-auth").style.display = "none";
|
||||
document.getElementById(`view-${name}`).style.display = "block";
|
||||
}
|
||||
|
||||
function connect() {
|
||||
initSession()
|
||||
.then((data) => {
|
||||
const stopPolling = startPolling(data.csrf_token, {
|
||||
onSuccess: (sessionData) => {
|
||||
saveSession(sessionData).then(() => showView("auth"));
|
||||
},
|
||||
onTimeout: () => {
|
||||
showView("unauth");
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
},
|
||||
});
|
||||
openTransitDialog(data.transit_token, {
|
||||
onCancel: () => stopPolling(),
|
||||
onError: (err) => {
|
||||
stopPolling();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
clearSession().finally(() => showView("unauth"));
|
||||
}
|
||||
|
||||
function _setButtonLoading() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Génération...";
|
||||
}
|
||||
|
||||
function _setButtonIdle() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = false;
|
||||
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
|
||||
}
|
||||
|
||||
function generateMeetingLink() {
|
||||
const session = loadSession();
|
||||
if (!session?.access_token) {
|
||||
console.error("Session introuvable. Veuillez vous reconnecter.");
|
||||
showView("unauth");
|
||||
return;
|
||||
}
|
||||
|
||||
_setButtonLoading();
|
||||
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const item = Office.context.mailbox.item;
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(getResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
item.body.setAsync(
|
||||
getResult.value + message,
|
||||
{ coercionType: Office.CoercionType.Html },
|
||||
(setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(setResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── If calendar event, also set location ──────────────
|
||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||
item.location.setAsync(url, () => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
_setButtonIdle();
|
||||
});
|
||||
}
|
||||
|
||||
Office.onReady((info) => {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
document.getElementById("sideload-msg").style.display = "none";
|
||||
document.getElementById("app-body").style.display = "flex";
|
||||
document.getElementById("btn-connect").onclick = connect;
|
||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
||||
|
||||
const session = loadSession();
|
||||
if (session?.state === "authenticated" && session?.access_token) {
|
||||
showView("auth");
|
||||
} else {
|
||||
showView("unauth");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div
|
||||
class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<svg
|
||||
class="spinner-svg"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Background track -->
|
||||
<circle class="spinner-track" cx="14" cy="14" r="11"
|
||||
/>
|
||||
<!-- Rotating arc -->
|
||||
<circle class="spinner-arc" cx="14" cy="14" r="11"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
|
||||
<span class="spinner-fallback" aria-hidden="true">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="display: block; transform: translateY(1px);"
|
||||
>
|
||||
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { URLS } = require("../common/urls");
|
||||
const { save } = require("../common/transitToken");
|
||||
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
||||
|
||||
// Initiate the authentication flow, then return to the success page
|
||||
function getAuthenticateUrl() {
|
||||
const url = new URL(URLS.authenticate);
|
||||
url.searchParams.set("returnTo", URLS.successPage);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
Office.onReady(function (info) {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
}
|
||||
|
||||
Office.context.ui.addHandlerAsync(
|
||||
Office.EventType.DialogParentMessageReceived,
|
||||
function (arg) {
|
||||
const transitToken = arg.message;
|
||||
|
||||
if (typeof transitToken !== "string" || transitToken.trim() === "") {
|
||||
console.error("Invalid transit token received from parent dialog.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Runs inside the dialog window.
|
||||
// Flow:
|
||||
// transit.html saves token → navigates to /authenticate → OAuth redirect →
|
||||
// success.html. sessionStorage survives because it's per-window-per-origin
|
||||
// and the dialog window persists across same-origin navigations.
|
||||
// Fragile: if the IdP opens the redirect in a new tab/window, this breaks
|
||||
// silently.
|
||||
// An alternative could be to pass the token via the OAuth `state` param
|
||||
// and read it back from the redirect URL.
|
||||
try {
|
||||
save(transitToken);
|
||||
Office.context.ui.messageParent(DIALOG_SIGNALS.done);
|
||||
window.location.href = getAuthenticateUrl();
|
||||
} catch (err) {
|
||||
console.error("Failed to store transit token:", err);
|
||||
}
|
||||
},
|
||||
function (result) {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
console.error("Failed to register DialogParentMessageReceived handler.", result.error);
|
||||
return;
|
||||
}
|
||||
Office.context.ui.messageParent(DIALOG_SIGNALS.ready);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const devCerts = require("office-addin-dev-certs");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
||||
|
||||
async function getHttpsOptions() {
|
||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||
}
|
||||
|
||||
module.exports = async (env, options) => {
|
||||
const config = {
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
||||
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
||||
commands: "./src/commands/commands.js",
|
||||
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
|
||||
success: ["./src/success/success.js", "./src/success/success.html"],
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".html", ".js"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "html-loader",
|
||||
options: {
|
||||
sources: {
|
||||
urlFilter: (attribute, value) => {
|
||||
// Don't try to resolve the runtime-injected config
|
||||
if (value.includes("config.js")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif|ico)$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name][ext][query]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "taskpane.html",
|
||||
template: "./src/taskpane/taskpane.html",
|
||||
chunks: ["polyfill", "taskpane"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "assets/*",
|
||||
to: "assets/[name][ext][query]",
|
||||
}
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "commands.html",
|
||||
template: "./src/commands/commands.html",
|
||||
chunks: ["polyfill", "commands"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "transit.html",
|
||||
template: "./src/transit/transit.html",
|
||||
chunks: ["polyfill", "transit"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "success.html",
|
||||
template: "./src/success/success.html",
|
||||
chunks: ["polyfill", "success"],
|
||||
scriptLoading: "defer",
|
||||
attributes: {
|
||||
nonce: "NONCE_PLACEHOLDER",
|
||||
},
|
||||
}),
|
||||
new htmlWebpackInjectAttributesPlugin(),
|
||||
],
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
server: {
|
||||
type: "https",
|
||||
options:
|
||||
env.WEBPACK_BUILD || options.https !== undefined
|
||||
? options.https
|
||||
: await getHttpsOptions(),
|
||||
},
|
||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim AS base
|
||||
FROM python:3.13.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -15,19 +15,30 @@ COPY pyproject.toml .
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
FROM base AS development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir ".[dev]"
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "metadata_collector.py", "dev"]
|
||||
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Remove pip to reduce attack surface in production
|
||||
RUN pip uninstall -y pip
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Un-privileged user running the application
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY ./*.py /app/
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||
CMD ["python", "multi_user_transcriber.py", "start"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Storage parsers specific exceptions."""
|
||||
|
||||
|
||||
class MissingConfigError(Exception):
|
||||
"""Raised when a variable is not set in configuration."""
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Metadata agent that extracts metadata from active room."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
AgentServer,
|
||||
AgentSession,
|
||||
AutoSubscribe,
|
||||
JobContext,
|
||||
JobProcess,
|
||||
JobRequest,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("metadata-collector")
|
||||
|
||||
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
||||
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
server = AgentServer(
|
||||
permissions=WorkerPermissions(
|
||||
can_publish=False,
|
||||
can_publish_data=False,
|
||||
can_subscribe=True,
|
||||
hidden=True,
|
||||
),
|
||||
)
|
||||
server.setup_fnc = prewarm
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataEvent:
|
||||
"""A single timestamped event recorded during a meeting."""
|
||||
|
||||
participant_id: str
|
||||
type: str
|
||||
timestamp: datetime
|
||||
data: Optional[str] = None
|
||||
|
||||
def serialize(self) -> dict:
|
||||
"""Return a JSON-serializable dictionary representation of the event."""
|
||||
data = asdict(self)
|
||||
data["timestamp"] = self.timestamp.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class VADAgent(Agent):
|
||||
"""Agent that monitors voice activity for a specific participant."""
|
||||
|
||||
def __init__(self, participant_identity: str, events: List):
|
||||
"""Initialize with a participant identity and shared events list."""
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
self.events = events
|
||||
|
||||
async def on_enter(self) -> None:
|
||||
"""Initialize VAD monitoring for this participant."""
|
||||
|
||||
@self.session.on("user_state_changed")
|
||||
def on_user_state(event):
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
if event.new_state == "speaking":
|
||||
event = MetadataEvent(
|
||||
participant_id=self.participant_identity,
|
||||
type="speech_start",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self.events.append(event)
|
||||
|
||||
elif event.old_state == "speaking":
|
||||
event = MetadataEvent(
|
||||
participant_id=self.participant_identity,
|
||||
type="speech_end",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
class MetadataCollector:
|
||||
"""Collect meeting events across all participants in a room.
|
||||
|
||||
Creates one AgentSession per participant to capture VAD events
|
||||
(speech start/end), and listens for connection, disconnection,
|
||||
and chat events. Persists all collected events as JSON to S3
|
||||
on shutdown.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: JobContext, recording_id: str):
|
||||
"""Initialize metadata agent."""
|
||||
self.minio_client = Minio(
|
||||
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
|
||||
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
|
||||
)
|
||||
|
||||
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
|
||||
self.bucket_name = bucket_name
|
||||
else:
|
||||
raise MissingConfigError
|
||||
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
|
||||
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
|
||||
|
||||
# Storage for events
|
||||
self.events = []
|
||||
self.participants = {}
|
||||
|
||||
logger.info("MetadataCollector initialized")
|
||||
|
||||
def start(self):
|
||||
"""Start listening for room-level events."""
|
||||
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
|
||||
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
|
||||
|
||||
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
|
||||
|
||||
logger.info("Started listening for participant events")
|
||||
|
||||
async def on_chat_message_received(
|
||||
self, reader: rtc.TextStreamReader, participant_identity: str
|
||||
):
|
||||
"""Read a complete chat message and record it as an event."""
|
||||
full_text = await reader.read_all()
|
||||
logger.info("Received chat message from %s", participant_identity)
|
||||
|
||||
self.events.append(
|
||||
MetadataEvent(
|
||||
participant_id=participant_identity,
|
||||
type="chat_received",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
data=full_text,
|
||||
)
|
||||
)
|
||||
|
||||
def handle_chat_stream(self, reader, participant_identity):
|
||||
"""Schedule async processing of an incoming chat stream."""
|
||||
task = asyncio.create_task(
|
||||
self.on_chat_message_received(reader, participant_identity)
|
||||
)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
logger.info("Persisting metadata…")
|
||||
|
||||
participants = []
|
||||
for k, v in self.participants.items():
|
||||
participants.append({"participantId": k, "name": v})
|
||||
|
||||
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
|
||||
|
||||
payload = {
|
||||
"events": [event.serialize() for event in sorted_events],
|
||||
"participants": participants,
|
||||
}
|
||||
|
||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||
stream = BytesIO(data)
|
||||
|
||||
try:
|
||||
self.minio_client.put_object(
|
||||
self.bucket_name,
|
||||
self.output_filename,
|
||||
stream,
|
||||
length=len(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
logger.info(
|
||||
"Uploaded speaker meeting metadata",
|
||||
)
|
||||
except S3Error:
|
||||
logger.exception(
|
||||
"Failed to upload meeting metadata",
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
"""Close all sessions and cleanup resources."""
|
||||
logger.info("Closing all VAD monitoring sessions…")
|
||||
|
||||
await utils.aio.cancel_and_wait(*self._tasks)
|
||||
|
||||
await asyncio.gather(
|
||||
*[self._close_session(session) for session in self._sessions.values()],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
|
||||
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
|
||||
|
||||
logger.info("All VAD sessions closed")
|
||||
self.save()
|
||||
|
||||
async def on_participant_entrypoint(
|
||||
self, ctx: JobContext, participant: rtc.RemoteParticipant
|
||||
):
|
||||
"""Handle new participant by starting a VAD monitoring session."""
|
||||
if participant.identity in self._sessions:
|
||||
logger.debug("Session already exists for %s", participant.identity)
|
||||
return
|
||||
|
||||
self.events.append(
|
||||
MetadataEvent(
|
||||
participant_id=participant.identity,
|
||||
type="participant_connected",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
self.participants[participant.identity] = participant.name
|
||||
|
||||
logger.info("New participant connected: %s", participant.identity)
|
||||
try:
|
||||
session = await self._start_session(participant)
|
||||
self._sessions[participant.identity] = session
|
||||
except Exception:
|
||||
logger.exception("Failed to start session for %s", participant.identity)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle participant disconnection by closing VAD monitoring."""
|
||||
self.events.append(
|
||||
MetadataEvent(
|
||||
participant_id=participant.identity,
|
||||
type="participant_disconnected",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
session = self._sessions.pop(participant.identity, None)
|
||||
if session is None:
|
||||
logger.debug("No session found for %s", participant.identity)
|
||||
return
|
||||
|
||||
logger.info("Participant disconnected: %s", participant.identity)
|
||||
task = asyncio.create_task(self._close_session(session))
|
||||
self._tasks.add(task)
|
||||
|
||||
def on_close_done(_):
|
||||
self._tasks.discard(task)
|
||||
logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
)
|
||||
|
||||
task.add_done_callback(on_close_done)
|
||||
|
||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||
"""Update stored participant name when it changes."""
|
||||
logger.info("Participant's name changed: %s", participant.identity)
|
||||
self.participants[participant.identity] = participant.name
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start VAD monitoring session for participant."""
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
# Create session with VAD only - no STT, LLM, or TTS
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
turn_detection="vad",
|
||||
user_away_timeout=30.0,
|
||||
)
|
||||
|
||||
# Set up room IO to receive audio from this specific participant
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
audio_enabled=True,
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
audio_enabled=False,
|
||||
transcription_enabled=False,
|
||||
),
|
||||
)
|
||||
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=VADAgent(
|
||||
participant_identity=participant.identity, events=self.events
|
||||
)
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
async def _close_session(self, session: AgentSession) -> None:
|
||||
"""Close and cleanup VAD monitoring session."""
|
||||
try:
|
||||
await session.drain()
|
||||
await session.aclose()
|
||||
except Exception:
|
||||
logger.exception("Error closing session")
|
||||
|
||||
|
||||
async def handle_job_request(job_req: JobRequest) -> None:
|
||||
"""Accept or reject the job request based on agent presence in the room."""
|
||||
room_name = job_req.room.name
|
||||
recording_id = job_req.job.metadata
|
||||
agent_identity = f"{AGENT_NAME}-{room_name}"
|
||||
|
||||
async with api.LiveKitAPI() as lk:
|
||||
try:
|
||||
resp = await lk.room.list_participants(
|
||||
list=api.ListParticipantsRequest(room=room_name)
|
||||
)
|
||||
already_present = any(
|
||||
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
|
||||
and p.identity == agent_identity
|
||||
for p in resp.participants
|
||||
)
|
||||
if already_present:
|
||||
logger.info("Agent already in the room '%s' — reject", room_name)
|
||||
await job_req.reject()
|
||||
else:
|
||||
logger.info(
|
||||
"Accept job for '%s' — identity=%s", room_name, agent_identity
|
||||
)
|
||||
await job_req.accept(identity=agent_identity, metadata=recording_id)
|
||||
except Exception:
|
||||
logger.exception("Error treating the job for '%s'", room_name)
|
||||
await job_req.reject()
|
||||
|
||||
|
||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the metadata collector."""
|
||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||
recording_id = ctx.job.metadata
|
||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||
metadata_collector.start()
|
||||
|
||||
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
|
||||
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
async def cleanup():
|
||||
logger.info("Shutting down metadata collector…")
|
||||
await metadata_collector.aclose()
|
||||
|
||||
ctx.add_shutdown_callback(cleanup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli.run_app(server)
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.12.0"
|
||||
version = "1.15.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
@@ -9,7 +9,8 @@ dependencies = [
|
||||
"livekit-plugins-silero==1.4.5",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.5"
|
||||
"protobuf==6.33.5",
|
||||
"minio==7.2.15"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -17,6 +18,9 @@ dev = [
|
||||
"ruff==0.15.6",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core add-ons module."""
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from core.models import User
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
_PUBLIC_SESSION_FIELDS = frozenset(
|
||||
{"state", "access_token", "token_type", "expires_in", "scope"}
|
||||
)
|
||||
|
||||
|
||||
class SessionDataError(Exception):
|
||||
"""Raised when session data is invalid or malformed."""
|
||||
|
||||
|
||||
class CSRFTokenError(Exception):
|
||||
"""Raised when CSRF token verification fails."""
|
||||
|
||||
|
||||
class TransitTokenError(Exception):
|
||||
"""Raised when a transit token is invalid or expired."""
|
||||
|
||||
|
||||
class SessionExpiredError(Exception):
|
||||
"""Raised when a session has expired."""
|
||||
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
"""Raised when a session is not found."""
|
||||
|
||||
|
||||
class SuspiciousSessionError(Exception):
|
||||
"""Raised when session state indicates a possible attack or bug."""
|
||||
|
||||
|
||||
class SessionState(str, Enum):
|
||||
"""Add-on authentication session lifecycle states."""
|
||||
|
||||
PENDING = "pending"
|
||||
AUTHENTICATED = "authenticated"
|
||||
|
||||
|
||||
class TransitTokenState(str, Enum):
|
||||
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
|
||||
|
||||
PENDING = "pending"
|
||||
CONSUMED = "consumed"
|
||||
|
||||
|
||||
class TokenExchangeService:
|
||||
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
||||
|
||||
def __init__(self):
|
||||
"""Build the underlying JWT service and validate required settings."""
|
||||
|
||||
if not settings.ADDONS_CSRF_SECRET:
|
||||
raise ImproperlyConfigured("CSRF Secret is required.")
|
||||
|
||||
if not settings.ADDONS_TOKEN_SCOPE:
|
||||
raise ImproperlyConfigured("Token scope must be defined.")
|
||||
|
||||
self._token_service = JwtTokenService(
|
||||
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
issuer=settings.ADDONS_TOKEN_ISSUER,
|
||||
audience=settings.ADDONS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.ADDONS_TOKEN_TTL,
|
||||
token_type=settings.ADDONS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(prefix: str, token: str) -> str:
|
||||
"""Build a namespaced cache key: ``addons_{prefix}_{token}``."""
|
||||
return f"addons_{prefix}_{token}"
|
||||
|
||||
@staticmethod
|
||||
def _derive_csrf_token(session_id: str) -> str:
|
||||
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET."""
|
||||
return hmac.new(
|
||||
settings.ADDONS_CSRF_SECRET.encode("utf-8"),
|
||||
session_id.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_not_expired(session_data: dict) -> int:
|
||||
"""Return remaining seconds until expiry, or raise if missing/malformed/expired."""
|
||||
expires_at_str = session_data.get("expires_at")
|
||||
if expires_at_str is None:
|
||||
raise SessionDataError("Invalid session data: missing expiration.")
|
||||
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
except ValueError as e:
|
||||
raise SessionDataError("Invalid session data: malformed expiration.") from e
|
||||
|
||||
remaining_seconds = int(
|
||||
(expires_at - datetime.now(timezone.utc)).total_seconds()
|
||||
)
|
||||
|
||||
if remaining_seconds <= 0:
|
||||
raise SessionExpiredError("Session expired.")
|
||||
|
||||
return remaining_seconds
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate a high-entropy URL-safe session_id."""
|
||||
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
|
||||
|
||||
def _generate_transit_token(self) -> str:
|
||||
"""Generate a high-entropy URL-safe transit token."""
|
||||
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
|
||||
|
||||
def init_session(self) -> tuple[str, str, str]:
|
||||
"""Create a new pending session and its transit binding.
|
||||
|
||||
Returns:
|
||||
(transit_token, session_id, csrf_token)
|
||||
"""
|
||||
session_id = self._generate_session_id()
|
||||
transit_token = self._generate_transit_token()
|
||||
csrf_token = self._derive_csrf_token(session_id)
|
||||
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL)
|
||||
).isoformat()
|
||||
|
||||
session_data = {
|
||||
"state": SessionState.PENDING,
|
||||
"expires_at": expires_at,
|
||||
"transit_token": transit_token,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
||||
session_data,
|
||||
settings.ADDONS_SESSION_TTL,
|
||||
)
|
||||
|
||||
transit_token_data = {
|
||||
"session_id": session_id,
|
||||
"state": TransitTokenState.PENDING,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token),
|
||||
transit_token_data,
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL,
|
||||
)
|
||||
|
||||
return transit_token, session_id, csrf_token
|
||||
|
||||
def verify_csrf(self, session_id: str, submitted_csrf: str) -> None:
|
||||
"""Constant-time verify submitted_csrf against HMAC(session_id). Raise on mismatch."""
|
||||
expected_csrf = self._derive_csrf_token(session_id)
|
||||
if not hmac.compare_digest(expected_csrf, submitted_csrf):
|
||||
raise CSRFTokenError("Invalid CSRF token.")
|
||||
|
||||
def consume_transit_token(self, transit_token: str) -> str:
|
||||
"""Mark transit token consumed and return its session_id.
|
||||
|
||||
A replay (second consume) evicts the session as a security cleanup and raises.
|
||||
|
||||
Raises:
|
||||
TransitTokenError: If token is unknown, expired, or already consumed.
|
||||
"""
|
||||
cache_key = self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
||||
|
||||
transit_token_data = cache.get(cache_key)
|
||||
|
||||
if transit_token_data is None:
|
||||
# Indistinguishable from here: either the token was never issued (attacker
|
||||
# probing or client bug) or it was issued but expired before consumption.
|
||||
logger.warning(
|
||||
"Transit token not found in cache (unknown or expired).",
|
||||
)
|
||||
raise TransitTokenError("Invalid or expired transit token.")
|
||||
|
||||
state = transit_token_data.get("state", None)
|
||||
session_id = transit_token_data.get("session_id", None)
|
||||
|
||||
if not session_id:
|
||||
logger.warning("Transit token data missing session_id.")
|
||||
raise TransitTokenError("Invalid transit token.")
|
||||
|
||||
if state == TransitTokenState.CONSUMED:
|
||||
logger.warning(
|
||||
"Replay on session %s",
|
||||
session_id,
|
||||
)
|
||||
|
||||
# Security cleanup: a replay attempt means the transit token leaked
|
||||
# (or an attacker is probing). Evict the session so the authenticated
|
||||
# tokens — if they exist — can no longer be polled.
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
|
||||
raise TransitTokenError("Transit token already consumed.")
|
||||
|
||||
new_transit_token_data = {
|
||||
"state": TransitTokenState.CONSUMED,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
cache_key,
|
||||
new_transit_token_data,
|
||||
settings.ADDONS_SESSION_TTL,
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
@staticmethod
|
||||
def is_session_pending(session_data: dict) -> bool:
|
||||
"""Return True if the public session dict is still in the pending state."""
|
||||
return session_data.get("state") == SessionState.PENDING
|
||||
|
||||
def _get_session_data(self, session_id: str) -> dict:
|
||||
"""Fetch raw session data from cache, or raise SessionNotFoundError."""
|
||||
|
||||
if not session_id:
|
||||
raise SessionNotFoundError("Session not found.")
|
||||
|
||||
data = cache.get(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
if data is None:
|
||||
raise SessionNotFoundError("Session not found.")
|
||||
return data
|
||||
|
||||
def get_session(self, session_id: str) -> dict:
|
||||
"""Return the public session view; evict the session on authenticated read.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If session is not found.
|
||||
SessionDataError: If session data is missing the state field.
|
||||
"""
|
||||
|
||||
# raises if session is not found
|
||||
session_data = self._get_session_data(session_id)
|
||||
|
||||
if "state" not in session_data:
|
||||
raise SessionDataError("Invalid session data: missing state field.")
|
||||
|
||||
# One-time read: clear both bindings for authenticated sessions
|
||||
if session_data["state"] == SessionState.AUTHENTICATED:
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
|
||||
# Return public fields only
|
||||
return {k: v for k, v in session_data.items() if k in _PUBLIC_SESSION_FIELDS}
|
||||
|
||||
def _validate_transit_token_state(self, session_data: dict) -> None:
|
||||
"""Assert the session's transit token exists in cache and is in CONSUMED state.
|
||||
|
||||
Raises:
|
||||
SessionDataError: session_data is missing the transit_token field.
|
||||
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
|
||||
"""
|
||||
|
||||
transit_token = session_data.get("transit_token", None)
|
||||
if transit_token is None:
|
||||
raise SessionDataError("Invalid session data: missing transit_token field.")
|
||||
|
||||
transit_token_data = cache.get(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
|
||||
)
|
||||
|
||||
if transit_token_data is None:
|
||||
logger.warning("Transit token missing when setting access token.")
|
||||
raise SuspiciousSessionError("Transit token not found.")
|
||||
|
||||
if transit_token_data.get("state") != TransitTokenState.CONSUMED:
|
||||
logger.warning("Access token requested without completing transit flow.")
|
||||
raise SuspiciousSessionError("Transit token not consumed.")
|
||||
|
||||
def set_access_token(self, user: User, session_id: str) -> None:
|
||||
"""Authenticate a pending session by minting a JWT and storing it on the session.
|
||||
|
||||
Non-pending sessions are evicted as a security cleanup before raising.
|
||||
|
||||
Raises:
|
||||
SessionNotFoundError: If session doesn't exist.
|
||||
SessionDataError: If session data is malformed.
|
||||
SessionExpiredError: If session has expired.
|
||||
SuspiciousSessionError: If session is not pending or transit wasn't consumed.
|
||||
"""
|
||||
|
||||
# raises if session is not found
|
||||
session_data = self._get_session_data(session_id)
|
||||
|
||||
if session_data.get("state") != SessionState.PENDING:
|
||||
logger.warning(
|
||||
"Session's state is not pending. Suspicious.",
|
||||
)
|
||||
# Security cleanup: evict the session so any cached tokens cannot be polled.
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
raise SuspiciousSessionError("Session is not in pending state.")
|
||||
|
||||
# raises if transit_token is invalid
|
||||
try:
|
||||
self._validate_transit_token_state(session_data)
|
||||
except SuspiciousSessionError:
|
||||
# Security cleanup: evict the session.
|
||||
cache.delete(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
|
||||
)
|
||||
raise
|
||||
|
||||
# raises if session is expired
|
||||
remaining_seconds = self._validate_session_not_expired(session_data)
|
||||
|
||||
response = self._token_service.generate_jwt(user, settings.ADDONS_TOKEN_SCOPE)
|
||||
|
||||
new_data = {
|
||||
"access_token": response["access_token"],
|
||||
"token_type": response["token_type"],
|
||||
"expires_in": response["expires_in"],
|
||||
"scope": response["scope"],
|
||||
"expires_at": session_data["expires_at"],
|
||||
"state": SessionState.AUTHENTICATED,
|
||||
}
|
||||
|
||||
cache.set(
|
||||
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
|
||||
new_data,
|
||||
remaining_seconds,
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Add-ons API endpoints"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from rest_framework import decorators, viewsets
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import status as drf_status
|
||||
|
||||
from core.addons.service import (
|
||||
CSRFTokenError,
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
TransitTokenError,
|
||||
)
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.api.permissions import IsAuthenticated
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SessionViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for managing add-on authentication sessions via token exchange.
|
||||
|
||||
Implements a three-step flow that lets a third-party add-on (running in an
|
||||
embedded iframe) obtain an access token without exposing it to client-side
|
||||
JavaScript:
|
||||
|
||||
1. /init: the add-on opens a session and receives a short-lived transit
|
||||
token (used to bootstrap the OAuth-style exchange in a dialog) and a
|
||||
CSRF token. The opaque session id is stored in an HttpOnly, Secure,
|
||||
SameSite=None cookie so it can accompany cross-origin polls.
|
||||
2. /poll: the add-on polls until the session transitions from pending to
|
||||
authenticated. On the terminal read, the session payload (access
|
||||
token, token type, expiry, etc.) is returned, the session is evicted
|
||||
server-side, and the session cookie is cleared so the tokens can be
|
||||
retrieved exactly once.
|
||||
3. /exchange: called from the post-login callback page on our own domain,
|
||||
after the user has authenticated in a dialog opened by the addon. The
|
||||
transit token (carried client-side via postMessage + sessionStorage)
|
||||
is redeemed here for the authenticated user's access token, which is
|
||||
stored server-side against the session. Requires an authenticated
|
||||
user — that user is whose access token gets bound to the session.
|
||||
|
||||
/init and /poll authenticate the caller through the session cookie +
|
||||
CSRF token pair alone — no user login is required, since the whole point
|
||||
of the flow is to bootstrap one. /exchange, by contrast, requires an
|
||||
authenticated user and does not use the addonsSid cookie.
|
||||
"""
|
||||
|
||||
throttle_classes = []
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="init",
|
||||
authentication_classes=[],
|
||||
permission_classes=[],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def init(self, request):
|
||||
"""Open a new add-on authentication session.
|
||||
|
||||
Creates a fresh session server-side and returns the credentials the
|
||||
add-on needs to drive the rest of the flow.
|
||||
"""
|
||||
|
||||
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
|
||||
|
||||
response = drf_response.Response(
|
||||
{"transit_token": transit_token, "csrf_token": csrf_token},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# SameSite=None allows the cookie to be sent on cross-origin requests,
|
||||
# which is required because the /poll endpoint is called from an iframe
|
||||
# embedded in a third-party site. Secure=True is mandatory when SameSite=None.
|
||||
# HttpOnly prevents JS access, so the cookie can only be read by the server.
|
||||
response.set_cookie(
|
||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
||||
value=session_id,
|
||||
max_age=settings.ADDONS_SESSION_TTL,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="None",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="poll",
|
||||
authentication_classes=[],
|
||||
permission_classes=[],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def poll(self, request):
|
||||
"""Poll a session for its current state and, if terminal, consume it.
|
||||
|
||||
Authenticates the caller using the addonsSid cookie (set by
|
||||
/init) together with the X-CSRFToken header, which must match
|
||||
the CSRF token issued for that session. The session id alone is not
|
||||
sufficient — both must be presented and must correspond.
|
||||
|
||||
Behavior depends on the session's current state:
|
||||
|
||||
- **Pending**: the token exchange has not yet completed. Returns
|
||||
202 Accepted with `{"state": "pending"}`. The cookie is preserved
|
||||
so the add-on can keep polling.
|
||||
- **Authenticated** (or any other terminal state): returns 200 OK
|
||||
with the session payload (access token, token type, expiry, etc.)
|
||||
and clears the `addonsSid` cookie. The session is also evicted
|
||||
server-side on this terminal read, so the tokens can be retrieved
|
||||
exactly once.
|
||||
|
||||
A CSRF mismatch is treated as a `SuspiciousOperation` rather than a
|
||||
normal 4xx, so it is logged by Django's security middleware and
|
||||
surfaced as a 400 without leaking which check failed.
|
||||
"""
|
||||
|
||||
session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE)
|
||||
submitted_csrf = request.headers.get("X-CSRFToken")
|
||||
|
||||
if not session_id:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing credentials."},
|
||||
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not submitted_csrf:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing CSRF token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = TokenExchangeService()
|
||||
|
||||
try:
|
||||
service.verify_csrf(session_id, submitted_csrf)
|
||||
except CSRFTokenError as e:
|
||||
raise SuspiciousOperation(str(e)) from e
|
||||
|
||||
try:
|
||||
session = service.get_session(session_id)
|
||||
except SessionNotFoundError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Session not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except SessionDataError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired session."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if service.is_session_pending(session):
|
||||
return drf_response.Response(
|
||||
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
response = drf_response.Response(session, status=drf_status.HTTP_200_OK)
|
||||
response.delete_cookie(
|
||||
key=settings.ADDONS_SESSION_ID_COOKIE,
|
||||
samesite="None",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="exchange",
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
@FeatureFlag.require("addons")
|
||||
def exchange(self, request):
|
||||
"""Redeem a transit token for an access token bound to the current user.
|
||||
|
||||
Called from the post-OIDC callback page on our own domain. The transit
|
||||
token was issued by /init, passed to the authentication dialog via
|
||||
postMessage, stashed in sessionStorage, and read back by this page
|
||||
after login completes.
|
||||
|
||||
The authenticated user (request.user) is whose access token gets stored
|
||||
against the session. On success, the addon's next /poll will transition
|
||||
from pending to authenticated and receive the token payload.
|
||||
|
||||
Transit tokens are single-use: a replayed token is rejected with 400.
|
||||
"""
|
||||
|
||||
transit_token = request.data.get("transit_token")
|
||||
if not transit_token:
|
||||
return drf_response.Response(
|
||||
{"detail": "Missing transit_token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = TokenExchangeService()
|
||||
|
||||
try:
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
except TransitTokenError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired transit token."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
service.set_access_token(request.user, session_id)
|
||||
except SessionNotFoundError:
|
||||
return drf_response.Response(
|
||||
{"detail": "Session not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except (SessionDataError, SessionExpiredError, SuspiciousSessionError):
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid or expired session."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)
|
||||
@@ -14,6 +14,8 @@ class FeatureFlag:
|
||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
@@ -131,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""Validate room configuration against the RoomConfiguration schema."""
|
||||
if value is None or value == {}:
|
||||
return value
|
||||
try:
|
||||
RoomConfiguration.model_validate(value)
|
||||
except PydanticValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -232,11 +243,14 @@ class RecordingOptions(BaseModel):
|
||||
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"}
|
||||
@@ -303,6 +317,22 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
RoomConfigurationTrackSource = Literal[
|
||||
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||
]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
"""Validate room configuration structure.
|
||||
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
|
||||
|
||||
@@ -526,3 +556,15 @@ class CreateFileSerializer(ListFileSerializer):
|
||||
|
||||
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)
|
||||
|
||||
@@ -6,10 +6,13 @@ from logging import getLogger
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -44,6 +47,10 @@ from core.recording.event.exceptions import (
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
@@ -64,6 +71,7 @@ from core.services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
@@ -314,16 +322,27 @@ class RoomViewSet(
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
)
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
# pass that check and the DB-level UNIQUE constraint catches the loser.
|
||||
return drf_response.Response(
|
||||
{"error": f"A recording is already in progress for room {room.slug}"},
|
||||
status=drf_status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
@@ -331,11 +350,22 @@ class RoomViewSet(
|
||||
try:
|
||||
worker_manager.start(recording)
|
||||
except RecordingStartError:
|
||||
models.Recording.objects.filter(pk=recording.pk).update(
|
||||
status=models.RecordingStatusChoices.FAILED_TO_START
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status=drf_status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and (
|
||||
recording.options.get("collect_metadata", False)
|
||||
):
|
||||
try:
|
||||
MetadataCollectorService().start(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to start MetadataCollectorService")
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording successfully started for room {room.slug}"},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
@@ -598,6 +628,11 @@ class RoomViewSet(
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
track_sid=serializer.validated_data["track_sid"],
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to mute participant"},
|
||||
@@ -636,6 +671,11 @@ class RoomViewSet(
|
||||
permission=permission.model_dump() if permission else None,
|
||||
name=serializer.validated_data.get("name"),
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to update participant"},
|
||||
@@ -668,6 +708,11 @@ class RoomViewSet(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to remove participant"},
|
||||
@@ -678,6 +723,92 @@ class RoomViewSet(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="toggle-hand",
|
||||
url_name="toggle-hand",
|
||||
permission_classes=[permissions.HasLiveKitRoomAccess],
|
||||
authentication_classes=[LiveKitTokenAuthentication],
|
||||
)
|
||||
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Raise or lower the current participant's hand in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.RaiseHandSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
identity = request.auth.identity
|
||||
|
||||
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
|
||||
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
|
||||
# means the hand is raised. The timestamp is used by clients to determine
|
||||
# the order in which participants raised their hands.
|
||||
hand_raised_at = (
|
||||
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
|
||||
)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=identity,
|
||||
attributes={"handRaisedAt": hand_raised_at},
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to update participant hand state"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="rename",
|
||||
url_name="rename",
|
||||
permission_classes=[permissions.HasLiveKitRoomAccess],
|
||||
authentication_classes=[LiveKitTokenAuthentication],
|
||||
)
|
||||
def rename(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Rename the current participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
identity = request.auth.identity
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=identity,
|
||||
name=serializer.validated_data["name"],
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to rename participant"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
||||
@@ -14,10 +14,19 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
|
||||
def authenticate(self, request):
|
||||
token = request.data.get("token")
|
||||
if not token:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return None # No authentication attempted
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise exceptions.AuthenticationFailed(
|
||||
"Authorization header must be: Bearer <token>"
|
||||
)
|
||||
|
||||
token = parts[1]
|
||||
|
||||
try:
|
||||
verifier = TokenVerifier(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
|
||||
@@ -23,7 +23,14 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""Base JWT authentication class."""
|
||||
|
||||
def __init__(
|
||||
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
||||
self,
|
||||
secret_key,
|
||||
algorithm,
|
||||
issuer,
|
||||
audience,
|
||||
expiration_seconds,
|
||||
token_type,
|
||||
is_enabled,
|
||||
):
|
||||
"""Initialize the JWT authentication backend with the given token service configuration.
|
||||
|
||||
@@ -34,10 +41,17 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
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,
|
||||
@@ -54,6 +68,9 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
Tuple of (user, payload) if authentication successful, None otherwise
|
||||
"""
|
||||
|
||||
if not self.is_enabled:
|
||||
return None
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
@@ -186,6 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||
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):
|
||||
@@ -214,6 +232,26 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
|
||||
class AddonsJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for addons API access.
|
||||
|
||||
Validates JWT tokens issued to addons.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication backend with addons JWT settings from Django settings."""
|
||||
|
||||
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):
|
||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
from . import authentication, permissions, serializers
|
||||
@@ -36,6 +37,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
"""Generate JWT access token for application delegation.
|
||||
|
||||
@@ -173,6 +175,7 @@ class RoomViewSet(
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Meeting metadata collection service."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from asgiref.sync import async_to_sync, sync_to_async
|
||||
from livekit.protocol.agent_dispatch import (
|
||||
CreateAgentDispatchRequest,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataCollectorException(Exception):
|
||||
"""Generic exception in the metadata collector."""
|
||||
|
||||
|
||||
class MetadataCollectorService:
|
||||
"""Service for dispatching and managing the metadata collector agent."""
|
||||
|
||||
@async_to_sync
|
||||
async def start(self, recording: Recording):
|
||||
"""Explicitly dispatch the metadata collector agent to a room."""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
room_id = str(recording.room.id)
|
||||
|
||||
try:
|
||||
response = await lkapi.agent_dispatch.create_dispatch(
|
||||
CreateAgentDispatchRequest(
|
||||
agent_name=settings.METADATA_COLLECTOR_AGENT_NAME,
|
||||
room=room_id,
|
||||
metadata=str(recording.id),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to create metadata collector agent for room %s", room_id
|
||||
)
|
||||
raise MetadataCollectorException(
|
||||
"Failed to create metadata collector agent"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
dispatch_id = getattr(response, "id", None)
|
||||
|
||||
if not dispatch_id:
|
||||
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
|
||||
raise MetadataCollectorException(
|
||||
f"LiveKit did not return a dispatch_id for room {room_id}"
|
||||
)
|
||||
|
||||
recording.options["metadata_collector_dispatch_id"] = dispatch_id
|
||||
await sync_to_async(recording.save)(update_fields=["options"])
|
||||
|
||||
return dispatch_id
|
||||
|
||||
@async_to_sync
|
||||
async def stop(self, recording: Recording):
|
||||
"""Stop and delete the agent dispatch associated to the room."""
|
||||
|
||||
room_id = str(recording.room.id)
|
||||
dispatch_id = recording.options.get("metadata_collector_dispatch_id")
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
if not dispatch_id:
|
||||
logger.warning(
|
||||
"No metadata collector dispatch ID stored for room %s", room_id
|
||||
)
|
||||
return None
|
||||
|
||||
await lkapi.agent_dispatch.delete_dispatch(
|
||||
dispatch_id=str(dispatch_id), room_name=room_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to stop metadata collector agent dispatch for room %s",
|
||||
room_id,
|
||||
)
|
||||
raise MetadataCollectorException(
|
||||
f"Failed to stop metadata collector agent for room {room_id}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -7,6 +7,7 @@ from logging import getLogger
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -19,7 +20,7 @@ class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording, egress_status):
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
@@ -40,7 +41,7 @@ class RecordingEventsService:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording):
|
||||
def handle_limit_reached(recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
|
||||
@@ -12,6 +12,10 @@ from django.conf import settings
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
@@ -158,7 +162,7 @@ class LiveKitEventsService:
|
||||
"""Handle 'egress_ended' event."""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
)
|
||||
except models.Recording.DoesNotExist as err:
|
||||
@@ -174,6 +178,12 @@ class LiveKitEventsService:
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
|
||||
@@ -123,7 +123,7 @@ class LobbyService:
|
||||
|
||||
def request_entry(
|
||||
self,
|
||||
room,
|
||||
room: models.Room,
|
||||
request,
|
||||
username: str,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
|
||||
@@ -27,6 +27,10 @@ class ParticipantsManagementException(Exception):
|
||||
"""Exception raised when a participant management operations fail."""
|
||||
|
||||
|
||||
class ParticipantNotFoundException(ParticipantsManagementException):
|
||||
"""Raised when the target participant does not exist in the room."""
|
||||
|
||||
|
||||
class ParticipantsManagement:
|
||||
"""Service for managing participants."""
|
||||
|
||||
@@ -47,6 +51,14 @@ class ParticipantsManagement:
|
||||
)
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Participant %s not found in room %s, skipping muting",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error muting participant %s for room %s",
|
||||
identity,
|
||||
@@ -80,6 +92,14 @@ class ParticipantsManagement:
|
||||
RoomParticipantIdentity(room=room_name, identity=identity)
|
||||
)
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Participant %s not found in room %s, skipping removing",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error removing participant %s for room %s",
|
||||
identity,
|
||||
@@ -117,6 +137,14 @@ class ParticipantsManagement:
|
||||
)
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Participant %s not found in room %s, skipping update",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error updating participant %s for room %s",
|
||||
identity,
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Tests for add-ons API /sessions/init and /sessions/poll endpoints
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.addons.service import (
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ================================
|
||||
# endpoint /addons/sessions/init/
|
||||
# ================================
|
||||
|
||||
|
||||
def test_init_feature_flag_disabled(client, settings):
|
||||
"""Should return 404 on POST when feature is disabled."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
response = client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_init_only_accepts_post():
|
||||
"""Should return 201 JSON with only transit_token and csrf_token."""
|
||||
|
||||
response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response["Content-Type"] == "application/json"
|
||||
|
||||
response_data = response.json()
|
||||
# session_id must only be delivered via cookie, not in the same channel as csrf_token.
|
||||
assert set(response_data.keys()) == {"transit_token", "csrf_token"}
|
||||
|
||||
transit_token = response_data["transit_token"]
|
||||
|
||||
# URL-safe base64 alphabet: A-Z, a-z, 0-9, -, _
|
||||
assert re.match(r"^[A-Za-z0-9_-]+$", transit_token)
|
||||
|
||||
csrf_token = response_data["csrf_token"]
|
||||
# HMAC-SHA256 → 64-character hex string.
|
||||
assert re.match(r"^[a-f0-9]{64}$", csrf_token)
|
||||
|
||||
assert csrf_token != transit_token
|
||||
|
||||
|
||||
def test_init_rejects_non_post_methods():
|
||||
"""Should return 405 Method Not Allowed on GET."""
|
||||
response = APIClient().get("/api/v1.0/addons/sessions/init/")
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_init_generates_unique_tokens_across_calls():
|
||||
"""Should generate a distinct transit_token and csrf_token for every call."""
|
||||
|
||||
api_client = APIClient()
|
||||
|
||||
tokens = set()
|
||||
csrf_tokens = set()
|
||||
|
||||
for _ in range(5):
|
||||
response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
tokens.add(response.json()["transit_token"])
|
||||
csrf_tokens.add(response.json()["csrf_token"])
|
||||
|
||||
assert len(tokens) == 5
|
||||
assert len(csrf_tokens) == 5
|
||||
|
||||
|
||||
def test_init_cookie_authorizes_subsequent_poll():
|
||||
"""Should issue a session cookie that, with the returned csrf_token, authorizes /poll."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
assert "addonsSid" in init_response.cookies
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 202
|
||||
assert poll_response.json() == {"state": "pending"}
|
||||
|
||||
|
||||
def test_init_session_id_cookie_attributes(settings):
|
||||
"""Should set the session cookie with the security attributes required for iframe embedding."""
|
||||
response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
|
||||
cookies = response.cookies
|
||||
assert list(cookies) == ["addonsSid"] # only this cookie
|
||||
|
||||
cookie = cookies["addonsSid"]
|
||||
assert re.match(r"^[A-Za-z0-9_-]+$", cookie.value), "URL-safe base64 expected"
|
||||
assert cookie["httponly"] is True, (
|
||||
"HttpOnly required — cookie must not be JS-readable"
|
||||
)
|
||||
assert cookie["secure"] is True, (
|
||||
"Secure required — cookie must not travel over HTTP"
|
||||
)
|
||||
assert cookie["samesite"] == "None", (
|
||||
"SameSite=None required for cross-origin iframe"
|
||||
)
|
||||
assert cookie["max-age"] == settings.ADDONS_SESSION_TTL
|
||||
|
||||
|
||||
def test_init_session_id_cookie_respects_configured_name(settings):
|
||||
"""Should name the session cookie according to the ADDONS_SESSION_ID_COOKIE setting."""
|
||||
api_client = APIClient()
|
||||
|
||||
settings.ADDONS_SESSION_ID_COOKIE = "mockSessionSid"
|
||||
|
||||
response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert "mockSessionSid" in response.cookies
|
||||
assert response.cookies.get("mockSessionSid") is not None
|
||||
|
||||
|
||||
# =================================
|
||||
# endpoint /addons/sessions/poll/
|
||||
# =================================
|
||||
|
||||
|
||||
def test_poll_feature_flag_disabled(client, settings):
|
||||
"""Should return 404 on POST when feature is disabled."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
response = client.post("/api/v1.0/addons/sessions/poll/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_poll_rejects_missing_csrf_token():
|
||||
"""Should reject requests that carry the sessionSid cookie but omit the CSRF header."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
# X-CSRF-Token is deliberately omitted
|
||||
poll_response = api_client.post("/api/v1.0/addons/sessions/poll/")
|
||||
|
||||
assert poll_response.status_code == 400
|
||||
assert poll_response.json() == {"detail": "Missing CSRF token."}
|
||||
|
||||
|
||||
def test_poll_missing_cookie():
|
||||
"""Should return 401 when no sessionSid cookie is present."""
|
||||
api_client = APIClient()
|
||||
|
||||
poll_response = api_client.post("/api/v1.0/addons/sessions/poll/")
|
||||
|
||||
assert poll_response.status_code == 401
|
||||
assert poll_response.json() == {"detail": "Missing credentials."}
|
||||
|
||||
|
||||
def test_poll_rejects_invalid_csrf_token():
|
||||
"""Should reject requests carrying an invalid CSRF token."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN="invalid-csrf-token",
|
||||
)
|
||||
|
||||
# SuspiciousOperation translates to 400 via Django's exception middleware.
|
||||
assert poll_response.status_code == 400
|
||||
|
||||
|
||||
@patch(
|
||||
"core.addons.service.TokenExchangeService._get_session_data",
|
||||
side_effect=SessionNotFoundError("Session not found."),
|
||||
)
|
||||
def test_poll_session_not_found(mock_get_session_data):
|
||||
"""Should return 404 when the session is not found."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 404
|
||||
assert poll_response.json() == {"detail": "Session not found."}
|
||||
|
||||
|
||||
@patch(
|
||||
"core.addons.service.TokenExchangeService._get_session_data",
|
||||
side_effect=SessionDataError("Session corrupted."),
|
||||
)
|
||||
def test_poll_session_corrupted(mock_get_session_data):
|
||||
"""Should return 400 when the session is corrupted."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 400
|
||||
assert poll_response.json() == {"detail": "Invalid or expired session."}
|
||||
|
||||
|
||||
def test_poll_session_authenticated():
|
||||
"""Should return tokens and tears down the polling channel when authenticated."""
|
||||
api_client = APIClient()
|
||||
|
||||
init_response = api_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
session_id_cookie = init_response.cookies["addonsSid"]
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
# Simulate Authentication done in the opened dialog
|
||||
service = TokenExchangeService()
|
||||
service.consume_transit_token(transit_token)
|
||||
service.set_access_token(UserFactory(), session_id_cookie.value)
|
||||
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
|
||||
assert poll_response.status_code == 200
|
||||
response_data = poll_response.json()
|
||||
access_token = response_data.pop("access_token")
|
||||
assert isinstance(access_token, str) and access_token # non-empty string
|
||||
assert response_data == {
|
||||
"expires_in": 7200,
|
||||
"scope": "rooms:create",
|
||||
"state": "authenticated",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
# Verify the server cleared the addonsSid cookie
|
||||
cleared_cookie = poll_response.cookies["addonsSid"]
|
||||
assert cleared_cookie.value == ""
|
||||
assert cleared_cookie["max-age"] == 0
|
||||
|
||||
# Server cleared the addonsSid cookie; APIClient drops it → no credentials.
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert poll_response.status_code == 401
|
||||
|
||||
# Replay the original addonsSid: session was evicted on terminal read.
|
||||
api_client.cookies["addonsSid"] = session_id_cookie.value
|
||||
poll_response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert poll_response.status_code == 404
|
||||
assert poll_response.json() == {"detail": "Session not found."}
|
||||
|
||||
|
||||
def test_poll_two_clients_do_not_interfere():
|
||||
"""Two clients poll independently; CSRF tokens are bound to their own session."""
|
||||
client_a = APIClient()
|
||||
client_b = APIClient()
|
||||
|
||||
init_a = client_a.post("/api/v1.0/addons/sessions/init/")
|
||||
init_b = client_b.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_a.status_code == 201
|
||||
assert init_b.status_code == 201
|
||||
|
||||
csrf_a = init_a.json()["csrf_token"]
|
||||
csrf_b = init_b.json()["csrf_token"]
|
||||
poll_id_a = init_a.cookies["addonsSid"].value
|
||||
poll_id_b = init_b.cookies["addonsSid"].value
|
||||
|
||||
# Sessions must be distinct.
|
||||
assert csrf_a != csrf_b
|
||||
assert poll_id_a != poll_id_b
|
||||
|
||||
# Each client polls its own session.
|
||||
poll_a = client_a.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_a,
|
||||
)
|
||||
poll_b = client_b.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_b,
|
||||
)
|
||||
assert poll_a.status_code == 202
|
||||
assert poll_b.status_code == 202
|
||||
|
||||
# Cross-use (A's cookie + B's CSRF) must be rejected.
|
||||
cross_response = client_a.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_b,
|
||||
)
|
||||
assert cross_response.status_code == 400
|
||||
|
||||
# A's session transitioning to authenticated must not affect B.
|
||||
with patch(
|
||||
"core.addons.service.TokenExchangeService._get_session_data",
|
||||
return_value={
|
||||
"state": "authenticated",
|
||||
"expires_at": "foo",
|
||||
"access_token": "mock-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 100,
|
||||
},
|
||||
):
|
||||
poll_a = client_a.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_a,
|
||||
)
|
||||
assert poll_a.status_code == 200
|
||||
|
||||
poll_b = client_b.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_b,
|
||||
)
|
||||
assert poll_b.status_code == 202
|
||||
|
||||
|
||||
def test_poll_csrf_attack_does_not_disrupt_legitimate_client():
|
||||
"""CSRF attack using the pollId cookie must fail without burning the session."""
|
||||
legitimate = APIClient()
|
||||
|
||||
init_response = legitimate.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
session_id_value = init_response.cookies["addonsSid"].value
|
||||
|
||||
# Attacker has the cookie (SameSite=None) but not the CSRF token.
|
||||
attacker = APIClient()
|
||||
attacker.cookies["addonsSid"] = session_id_value
|
||||
|
||||
# No CSRF header
|
||||
attack_no_csrf = attacker.post("/api/v1.0/addons/sessions/poll/")
|
||||
assert attack_no_csrf.status_code == 400
|
||||
assert attack_no_csrf.json() == {"detail": "Missing CSRF token."}
|
||||
|
||||
# Fabricated CSRF token
|
||||
attack_bad_csrf = attacker.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN="attacker-guessed-token",
|
||||
)
|
||||
assert attack_bad_csrf.status_code == 400
|
||||
|
||||
# Legitimate client's session is still usable.
|
||||
legitimate_poll = legitimate.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert legitimate_poll.status_code == 202
|
||||
assert legitimate_poll.json() == {"state": "pending"}
|
||||
|
||||
|
||||
# =====================================
|
||||
# endpoint /addons/sessions/exchange/
|
||||
# =====================================
|
||||
|
||||
|
||||
def test_exchange_feature_flag_disabled(settings):
|
||||
"""Should return 404 on POST when feature is disabled."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post("/api/v1.0/addons/sessions/exchange/")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_requires_authentication():
|
||||
"""Should return 401 when the caller is not authenticated."""
|
||||
api_client = APIClient()
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": "irrelevant"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_exchange_rejects_missing_transit_token():
|
||||
"""Should return 400 when the request body has no transit_token."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Missing transit_token."}
|
||||
|
||||
|
||||
def test_exchange_rejects_empty_transit_token():
|
||||
"""Should return 400 when transit_token is present but empty."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": ""},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Missing transit_token."}
|
||||
|
||||
|
||||
def test_exchange_rejects_invalid_transit_token():
|
||||
"""Should return 400 when the transit token is unknown or malformed."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": "not-a-real-transit-token"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid or expired transit token."}
|
||||
|
||||
|
||||
def test_exchange_rejects_replayed_transit_token():
|
||||
"""Should return 400 when a transit token is reused after being consumed."""
|
||||
init_client = APIClient()
|
||||
init_response = init_client.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=UserFactory())
|
||||
|
||||
first = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
assert second.status_code == 400
|
||||
assert second.json() == {"detail": "Invalid or expired transit token."}
|
||||
|
||||
|
||||
def test_exchange_success_enables_poll_to_complete():
|
||||
"""Should bind tokens to the session so the polling completes."""
|
||||
# 1. Taskpane opens a session.
|
||||
taskpane = APIClient()
|
||||
init_response = taskpane.post("/api/v1.0/addons/sessions/init/")
|
||||
assert init_response.status_code == 201
|
||||
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
csrf_token = init_response.json()["csrf_token"]
|
||||
|
||||
# 2. Dialog completes OIDC; post-login page (authenticated, separate
|
||||
# client — no addonsSid cookie) calls /exchange with the transit token.
|
||||
dialog = APIClient()
|
||||
dialog.force_authenticate(user=UserFactory())
|
||||
|
||||
exchange_response = dialog.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
assert exchange_response.status_code == 200
|
||||
assert exchange_response.json() == {"status": "ok"}
|
||||
|
||||
# 3. Taskpane's next poll transitions from pending → authenticated.
|
||||
poll_response = taskpane.post(
|
||||
"/api/v1.0/addons/sessions/poll/",
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
assert poll_response.status_code == 200
|
||||
response_data = poll_response.json()
|
||||
assert response_data["state"] == "authenticated"
|
||||
assert response_data["token_type"] == "Bearer"
|
||||
assert isinstance(response_data["access_token"], str)
|
||||
assert response_data["access_token"]
|
||||
|
||||
|
||||
@patch(
|
||||
"core.addons.service.TokenExchangeService.set_access_token",
|
||||
side_effect=SessionNotFoundError("Session not found."),
|
||||
)
|
||||
def test_exchange_returns_when_session_missing(mock_set_access_token):
|
||||
"""Should return 404 when the session bound to the transit token is gone."""
|
||||
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=UserFactory())
|
||||
|
||||
response = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Session not found."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_error",
|
||||
[SessionDataError, SessionExpiredError, SuspiciousSessionError],
|
||||
)
|
||||
def test_exchange_on_invalid_session(service_error):
|
||||
"""Should return 400 on malformed, expired, or suspicious sessions."""
|
||||
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=UserFactory())
|
||||
|
||||
with patch(
|
||||
"core.addons.service.TokenExchangeService.set_access_token",
|
||||
side_effect=service_error("boom"),
|
||||
):
|
||||
response = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid or expired session."}
|
||||
|
||||
|
||||
def test_exchange_rejects_non_post_methods():
|
||||
"""Should return 405 Method Not Allowed on non-POST verbs."""
|
||||
api_client = APIClient()
|
||||
api_client.force_authenticate(user=UserFactory())
|
||||
|
||||
for method in ("get", "put", "patch", "delete"):
|
||||
response = getattr(api_client, method)("/api/v1.0/addons/sessions/exchange/")
|
||||
assert response.status_code == 405, f"{method.upper()} should be rejected"
|
||||
|
||||
|
||||
def test_exchange_binds_to_authenticated_user():
|
||||
"""Should pass the authenticated user to set_access_token."""
|
||||
init_response = APIClient().post("/api/v1.0/addons/sessions/init/")
|
||||
transit_token = init_response.json()["transit_token"]
|
||||
|
||||
expected_user = UserFactory()
|
||||
auth_client = APIClient()
|
||||
auth_client.force_authenticate(user=expected_user)
|
||||
|
||||
with patch(
|
||||
"core.addons.service.TokenExchangeService.set_access_token",
|
||||
return_value=None,
|
||||
) as mock_set:
|
||||
response = auth_client.post(
|
||||
"/api/v1.0/addons/sessions/exchange/",
|
||||
{"transit_token": transit_token},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_set.assert_called_once()
|
||||
called_user, _called_session_id = mock_set.call_args.args
|
||||
assert called_user == expected_user
|
||||
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
Unit tests for TokenExchangeService.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import pytest
|
||||
|
||||
from core.addons.service import (
|
||||
_PUBLIC_SESSION_FIELDS,
|
||||
CSRFTokenError,
|
||||
SessionDataError,
|
||||
SessionExpiredError,
|
||||
SessionNotFoundError,
|
||||
SessionState,
|
||||
SuspiciousSessionError,
|
||||
TokenExchangeService,
|
||||
TransitTokenError,
|
||||
TransitTokenState,
|
||||
)
|
||||
|
||||
from ...factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
"""Fresh service instance per test."""
|
||||
return TokenExchangeService()
|
||||
|
||||
|
||||
# ==============================
|
||||
# init
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_init_service_improperly_configured_secret_token(settings):
|
||||
"""Should raise ImproperlyConfigured when ADDONS_TOKEN_SECRET_KEY is unset."""
|
||||
|
||||
settings.ADDONS_TOKEN_SECRET_KEY = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="Secret key is required."):
|
||||
TokenExchangeService()
|
||||
|
||||
|
||||
def test_init_service_improperly_configured_token_scope(settings):
|
||||
"""Should raise ImproperlyConfigured when ADDONS_TOKEN_SCOPE is empty."""
|
||||
|
||||
settings.ADDONS_TOKEN_SCOPE = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="Token scope must be defined."):
|
||||
TokenExchangeService()
|
||||
|
||||
|
||||
def test_init_service_raises_when_csrf_secret_missing(settings):
|
||||
"""Should raise ImproperlyConfigured when ADDONS_CSRF_SECRET is unset."""
|
||||
settings.ADDONS_CSRF_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match="CSRF Secret is required."):
|
||||
TokenExchangeService()
|
||||
|
||||
|
||||
# ==============================
|
||||
# init_session
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_init_session_returns_three_distinct_tokens(service):
|
||||
"""Should return (transit_token, session_id, csrf_token), all distinct and non-empty."""
|
||||
transit_token, session_id, csrf_token = service.init_session()
|
||||
|
||||
assert transit_token
|
||||
assert session_id
|
||||
assert csrf_token
|
||||
assert len({transit_token, session_id, csrf_token}) == 3
|
||||
|
||||
|
||||
def test_init_session_starts_in_pending_state(service):
|
||||
"""Should return a pending initialized session."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
session_data = cache.get(f"addons_sid_{session_id}")
|
||||
|
||||
assert session_data["state"] == SessionState.PENDING
|
||||
assert "access_token" not in session_data
|
||||
|
||||
transit_data = cache.get(f"addons_transit_{transit_token}")
|
||||
|
||||
# Transit token should be bind to the same session
|
||||
assert transit_data.get("session_id") == session_id
|
||||
assert transit_data.get("state") == TransitTokenState.PENDING
|
||||
|
||||
|
||||
def test_init_session_bind_transit_token_with_session(service):
|
||||
"""Should bind transit_token with the initialized session."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
transit_data = cache.get(f"addons_transit_{transit_token}")
|
||||
|
||||
assert transit_data.get("session_id") == session_id
|
||||
assert transit_data.get("state") == TransitTokenState.PENDING
|
||||
|
||||
|
||||
def test_init_session_creates_independent_cache_entries(service):
|
||||
"""Should write to distinct cache keys when called multiple times."""
|
||||
transit_a, session_id_a, csrf_a = service.init_session()
|
||||
transit_b, session_id_b, csrf_b = service.init_session()
|
||||
|
||||
assert transit_a != transit_b
|
||||
assert session_id_a != session_id_b
|
||||
assert csrf_a != csrf_b
|
||||
|
||||
|
||||
def test_init_session_csrf_token_is_derived_from_session_id(service, settings):
|
||||
"""Should derive the csrf_token as HMAC(session_id, ADDONS_CSRF_SECRET)."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
# Same inputs, same output: derivation is pure.
|
||||
assert csrf_token == service._derive_csrf_token(session_id)
|
||||
assert csrf_token == service._derive_csrf_token(
|
||||
session_id
|
||||
) # deterministic across calls
|
||||
|
||||
assert len(csrf_token) == 64
|
||||
assert all(c in "0123456789abcdef" for c in csrf_token)
|
||||
|
||||
# CSRF token is bound to the secret: rotating it invalidates outstanding tokens.
|
||||
settings.ADDONS_CSRF_SECRET = "another-secret-entirely"
|
||||
assert csrf_token != service._derive_csrf_token(session_id)
|
||||
|
||||
# CSRF token is bound to the session_id: same secret, different session ≠ same token.
|
||||
settings.ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # restore
|
||||
_, other_session_id, _ = service.init_session()
|
||||
assert service._derive_csrf_token(session_id) != service._derive_csrf_token(
|
||||
other_session_id
|
||||
)
|
||||
|
||||
|
||||
def test_init_session_tokens_have_sufficient_entropy(service):
|
||||
"""Should be long enough by default that collision is negligible."""
|
||||
transit_token, session_id, csrf_token = service.init_session()
|
||||
|
||||
assert len(transit_token) >= 40
|
||||
assert len(session_id) >= 40
|
||||
assert len(csrf_token) == 64
|
||||
|
||||
|
||||
def test_init_session_respects_configured_ttls(service, settings):
|
||||
"""Should respect their respective TTL configured through settings."""
|
||||
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
session_a_ttl = cache.ttl(f"addons_sid_{session_id}")
|
||||
transit_a_ttl = cache.ttl(f"addons_transit_{transit_token}")
|
||||
|
||||
# By default, transit token has a shorter TTL
|
||||
assert transit_a_ttl < session_a_ttl
|
||||
|
||||
settings.ADDONS_SESSION_TTL = 3000
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL = 60
|
||||
|
||||
transit_token_b, session_id_b, _ = service.init_session()
|
||||
|
||||
session_b_ttl = cache.ttl(f"addons_sid_{session_id_b}")
|
||||
transit_b_ttl = cache.ttl(f"addons_transit_{transit_token_b}")
|
||||
|
||||
assert abs(session_b_ttl - 3000) <= 2
|
||||
assert abs(transit_b_ttl - 60) <= 2
|
||||
|
||||
|
||||
# ==============================
|
||||
# verify_csrf
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_verify_csrf_accepts_matching_token(service):
|
||||
"""Should verify against its session_id."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
assert service.verify_csrf(session_id, csrf_token) is None
|
||||
|
||||
|
||||
def test_verify_csrf_is_deterministic_for_same_session(service):
|
||||
"""Should yield the same token when deriving CSRF twice."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
# Verify once, then verify again, both must succeed because
|
||||
# _derive_csrf_token is a pure function of session_id + secret.
|
||||
# without raising exceptions;
|
||||
assert service.verify_csrf(session_id, csrf_token) is None
|
||||
assert service.verify_csrf(session_id, csrf_token) is None
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_after_secret_rotation(service, settings):
|
||||
"""Should invalidate tokens issued under the old secret when ADDONS_CSRF_SECRET was rotated."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
# Rotate the secret
|
||||
settings.ADDONS_CSRF_SECRET = "different-secret-entirely"
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id, csrf_token)
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_foreign_token(service):
|
||||
"""Should reject against another csrf_token."""
|
||||
_, session_id_a, _ = service.init_session()
|
||||
_, _, csrf_b = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id_a, csrf_b)
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_random_token(service):
|
||||
"""Should reject against a random csrf_token."""
|
||||
_, session_id_a, _ = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id_a, "wrong-csrf-value")
|
||||
|
||||
|
||||
def test_verify_csrf_rejects_empty_token(service):
|
||||
"""Should reject against an empty csrf_token."""
|
||||
_, session_id_a, _ = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id_a, "")
|
||||
|
||||
|
||||
def test_verify_csrf_is_case_sensitive(service):
|
||||
"""Should be case-sensitive (HMAC output is lowercase hex)."""
|
||||
_, session_id, csrf_token = service.init_session()
|
||||
|
||||
with pytest.raises(CSRFTokenError, match="Invalid CSRF token."):
|
||||
service.verify_csrf(session_id, csrf_token.upper())
|
||||
|
||||
|
||||
# ==============================
|
||||
# get_session
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_get_session_raises_when_missing(service):
|
||||
"""Should raise SessionNotFoundError for an unknown session_id."""
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.get_session("nonexistent-session-id")
|
||||
|
||||
|
||||
def test_get_session_authenticated_returns_token_then_evicts(service):
|
||||
"""Should return tokens once and evict session when authenticated."""
|
||||
|
||||
user = UserFactory()
|
||||
transit_token_a, session_id_a, _ = service.init_session()
|
||||
_, session_id_b, _ = service.init_session()
|
||||
|
||||
# Authenticate the session
|
||||
service.consume_transit_token(transit_token_a)
|
||||
service.set_access_token(user, session_id_a)
|
||||
|
||||
# First read: returns the token payload.
|
||||
result = service.get_session(session_id_a)
|
||||
assert result["state"] == SessionState.AUTHENTICATED
|
||||
assert "access_token" in result
|
||||
|
||||
# Assert session_a is evicted from the cache
|
||||
session_data_a = cache.get(f"addons_sid_{session_id_a}")
|
||||
assert session_data_a is None
|
||||
|
||||
# Second read: binding was evicted.
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.get_session(session_id_a)
|
||||
|
||||
# Assert session_b is untouched
|
||||
session_data_b = cache.get(f"addons_sid_{session_id_b}")
|
||||
assert session_data_b is not None
|
||||
assert session_data_b.get("state") == SessionState.PENDING
|
||||
|
||||
|
||||
def test_get_session_pending_preserve_cache(service):
|
||||
"""Should keep session state in cache when the session is pending."""
|
||||
|
||||
_, session_id, _ = service.init_session()
|
||||
|
||||
# First read: returns the pending session.
|
||||
result_1 = service.get_session(session_id)
|
||||
assert result_1["state"] == SessionState.PENDING
|
||||
assert "access_token" not in result_1
|
||||
|
||||
# Second read: returns the pending session.
|
||||
result_2 = service.get_session(session_id)
|
||||
assert result_2["state"] == SessionState.PENDING
|
||||
assert "access_token" not in result_2
|
||||
|
||||
|
||||
def test_get_session_pending_only_exposes_public_fields(service):
|
||||
"""Should only return whitelisted public fields when session is pending."""
|
||||
_, session_id, _ = service.init_session()
|
||||
|
||||
session = service.get_session(session_id)
|
||||
|
||||
assert set(session.keys()) <= _PUBLIC_SESSION_FIELDS
|
||||
assert session["state"] == SessionState.PENDING
|
||||
|
||||
assert "expires_at" not in session
|
||||
assert "transit_token" not in session
|
||||
|
||||
|
||||
def test_get_session_authenticated_only_exposes_public_fields(service):
|
||||
"""Should only return whitelisted public fields when session is authenticated."""
|
||||
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
# Authenticate the session
|
||||
user = UserFactory()
|
||||
service.consume_transit_token(transit_token)
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
session = service.get_session(session_id)
|
||||
|
||||
assert session["state"] == SessionState.AUTHENTICATED
|
||||
assert set(session.keys()) <= _PUBLIC_SESSION_FIELDS
|
||||
|
||||
assert "expires_at" not in session
|
||||
assert "transit_token" not in session
|
||||
|
||||
|
||||
def test_get_session_empty_string(service):
|
||||
"""Should raise SessionNotFoundError if session is empty."""
|
||||
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.get_session("")
|
||||
|
||||
|
||||
def test_get_session_corrupted_session_data(service):
|
||||
"""Should raise SessionDataError if session's data is corrupted."""
|
||||
session_id = "mock-corrupted-session-id"
|
||||
cache.set(f"addons_sid_{session_id}", {"invalid": "invalid-value"})
|
||||
|
||||
with pytest.raises(
|
||||
SessionDataError, match="Invalid session data: missing state field."
|
||||
):
|
||||
service.get_session(session_id)
|
||||
|
||||
|
||||
# ==============================
|
||||
# consume_transit_token
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_consume_transit_token_returns_session_id(service):
|
||||
"""Should return the session_id the transit token was bound to."""
|
||||
_, session_id, _ = service.init_session()
|
||||
transit_token = cache.get(f"addons_sid_{session_id}")["transit_token"]
|
||||
|
||||
returned_session_id = service.consume_transit_token(transit_token)
|
||||
|
||||
assert returned_session_id == session_id
|
||||
|
||||
|
||||
def test_consume_transit_token_replay_raises(service):
|
||||
"""Should raise on the second consume of the same transit token."""
|
||||
transit_token, _, _ = service.init_session()
|
||||
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
with pytest.raises(TransitTokenError, match="Transit token already consumed."):
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
|
||||
def test_consume_transit_token_replay_evicts_session(service):
|
||||
"""Should evict the session as security cleanup when a replay is detected."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
service.consume_transit_token(transit_token)
|
||||
assert service.get_session(session_id)
|
||||
|
||||
with pytest.raises(TransitTokenError):
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# After replay, the session is gone.
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
service.get_session(session_id)
|
||||
|
||||
|
||||
def test_consume_transit_token_raises_on_unknown_token(service):
|
||||
"""Should raise TransitTokenError when the transit token is unknown or expired."""
|
||||
|
||||
with pytest.raises(TransitTokenError, match="Invalid or expired transit token."):
|
||||
service.consume_transit_token("nonexistent-transit-token")
|
||||
|
||||
|
||||
def test_consume_transit_token_replay_when_session_already_gone(service):
|
||||
"""Should still detect replay even if the session was evicted independently."""
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# Simulate session evicted independently
|
||||
cache.delete(f"addons_sid_{session_id}")
|
||||
|
||||
with pytest.raises(TransitTokenError, match="Transit token already consumed."):
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
|
||||
def test_consume_transit_token_extends_ttl_for_replay_detection(service, settings):
|
||||
"""Should extend the consumed transit entry's TTL to session length."""
|
||||
settings.ADDONS_SESSION_TTL = 3000
|
||||
settings.ADDONS_TRANSIT_TOKEN_TTL = 60
|
||||
|
||||
transit_token, _, _ = service.init_session()
|
||||
|
||||
# Before consume: transit has the short TTL.
|
||||
assert cache.ttl(f"addons_transit_{transit_token}") <= 60 + 1
|
||||
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# After consume: TTL is extended to session length.
|
||||
assert cache.ttl(f"addons_transit_{transit_token}") > 60
|
||||
|
||||
|
||||
# ==============================
|
||||
# set_access_token
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_set_access_token_writes_jwt_fields_to_session(service, settings):
|
||||
"""Should populate the session with JWT fields and flip state to authenticated."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
session = service.get_session(session_id)
|
||||
assert session["state"] == SessionState.AUTHENTICATED
|
||||
assert session["access_token"]
|
||||
assert session["token_type"] == settings.ADDONS_TOKEN_TYPE
|
||||
assert session["expires_in"] == settings.ADDONS_TOKEN_TTL
|
||||
assert session["scope"] == settings.ADDONS_TOKEN_SCOPE
|
||||
|
||||
|
||||
def test_set_access_token_preserves_remaining_ttl(service, settings):
|
||||
"""Should inherit the pending session's remaining TTL rather than resetting it."""
|
||||
settings.ADDONS_SESSION_TTL = 3000
|
||||
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
ttl_before = cache.ttl(f"addons_sid_{session_id}")
|
||||
service.set_access_token(user, session_id)
|
||||
ttl_after = cache.ttl(f"addons_sid_{session_id}")
|
||||
|
||||
# TTL must not jump back to full — allow small tolerance for execution time.
|
||||
assert ttl_after <= ttl_before + 1
|
||||
# And it shouldn't have somehow grown beyond the session length either.
|
||||
assert ttl_after <= 3000
|
||||
|
||||
|
||||
def test_authenticating_one_session_leaves_others_pending(service):
|
||||
"""Should leave other pending sessions untouched when authenticating one."""
|
||||
user = UserFactory()
|
||||
|
||||
transit_a, session_id_a, _ = service.init_session()
|
||||
_, session_id_b, _ = service.init_session()
|
||||
|
||||
service.consume_transit_token(transit_a)
|
||||
service.set_access_token(user, session_id_a)
|
||||
|
||||
session_b = service.get_session(session_id_b)
|
||||
assert session_b["state"] == SessionState.PENDING
|
||||
assert "access_token" not in session_b
|
||||
|
||||
|
||||
def test_set_access_token_raises_when_transit_entry_missing(service):
|
||||
"""Should raise when the transit cache entry is gone (TTL expired or evicted)."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
|
||||
# Manually delete the transit entry, simulating expiry or eviction.
|
||||
cache.delete(f"addons_transit_{transit_token}")
|
||||
|
||||
with pytest.raises(SuspiciousSessionError, match="Transit token not found."):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_raises_if_transit_token_not_consumed(service):
|
||||
"""Should refuse to authenticate a session whose transit token hasn't been consumed."""
|
||||
|
||||
user = UserFactory()
|
||||
_, session_id, _ = service.init_session()
|
||||
|
||||
with pytest.raises(SuspiciousSessionError, match="Transit token not consumed."):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
assert cache.get(f"addons_sid_{session_id}") is None
|
||||
|
||||
|
||||
def test_set_access_token_raises_on_missing_transit_token_field(service):
|
||||
"""Should raise SessionDataError when session data is missing the transit_token field."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
del corrupted["transit_token"]
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionDataError, match="missing transit_token field"):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_raises_if_double_authenticated(service):
|
||||
"""Should raise and wipe the session on double-auth while leaving the transit token intact."""
|
||||
user = UserFactory()
|
||||
transit_token, _, _ = service.init_session()
|
||||
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousSessionError, match="Session is not in pending state."
|
||||
):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
# Nuke session data as a security cleanup
|
||||
session_data = cache.get(f"addons_sid_{session_id}")
|
||||
assert session_data is None
|
||||
|
||||
transit_data = cache.get(f"addons_transit_{transit_token}")
|
||||
assert transit_data.get("state") == TransitTokenState.CONSUMED
|
||||
|
||||
|
||||
def test_set_access_token_raises_when_session_missing(service):
|
||||
"""Should raise SessionNotFoundError when called with an unknown session_id."""
|
||||
user = UserFactory()
|
||||
|
||||
with pytest.raises(SessionNotFoundError, match="Session not found."):
|
||||
service.set_access_token(user, "nonexistent-session-id")
|
||||
|
||||
|
||||
def test_set_access_token_rejects_malformed_expires_at(service):
|
||||
"""Should raise SessionDataError when the cached expires_at is not valid ISO 8601."""
|
||||
user = UserFactory()
|
||||
transit_token, _, _ = service.init_session()
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
|
||||
# Corrupt the cached session directly.
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
corrupted["expires_at"] = "not-an-iso-string"
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionDataError, match="malformed expiration"):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_rejects_missing_expires_at(service):
|
||||
"""Should raise SessionDataError when the cached session is missing the expires_at field."""
|
||||
user = UserFactory()
|
||||
transit_token, _, _ = service.init_session()
|
||||
session_id = service.consume_transit_token(transit_token)
|
||||
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
del corrupted["expires_at"]
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionDataError, match="missing expiration"):
|
||||
service.set_access_token(user, session_id)
|
||||
|
||||
|
||||
def test_set_access_token_raises_when_session_expired(service):
|
||||
"""Should raise SessionExpiredError when the cached session's expires_at is in the past."""
|
||||
user = UserFactory()
|
||||
transit_token, session_id, _ = service.init_session()
|
||||
service.consume_transit_token(transit_token)
|
||||
|
||||
# Simulate expiry: rewrite expires_at into the past.
|
||||
corrupted = cache.get(f"addons_sid_{session_id}")
|
||||
corrupted["expires_at"] = "2020-01-01T00:00:00+00:00"
|
||||
cache.set(f"addons_sid_{session_id}", corrupted, 3600)
|
||||
|
||||
with pytest.raises(SessionExpiredError, match="Session expired."):
|
||||
service.set_access_token(user, session_id)
|
||||
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc == "localhost:9000"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
msg="Internal server error", code="unknown", status=500
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
@@ -387,7 +387,7 @@ def test_update_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
msg="Internal server error", code="unknown", status=500
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
@@ -526,7 +526,7 @@ def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
msg="Internal server error", code="unknown", status=500
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
@@ -545,3 +545,55 @@ def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
assert response.data == {"error": "Failed to remove participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_not_found(mock_livekit_client):
|
||||
"""Test update participant returns 404 when the participant no longer exists in the room."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_remove_participant_not_found(mock_livekit_client):
|
||||
"""Test remove participant returns 404 when the participant no longer exists in the room."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4())}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
"""
|
||||
Test rooms API endpoints: toggle hand and rename participant.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from livekit.api import TwirpError
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_client():
|
||||
"""Mock LiveKit API client."""
|
||||
with mock.patch("core.utils.create_livekit_client") as mock_create:
|
||||
mock_client = mock.AsyncMock()
|
||||
mock_create.return_value = mock_client
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def room():
|
||||
"""Create a room."""
|
||||
return RoomFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
"""Create a user."""
|
||||
return UserFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token(room, user):
|
||||
"""Generate a real LiveKit JWT for the user in the room."""
|
||||
return utils.generate_token(room=str(room.id), user=user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anonymous_token(room):
|
||||
"""Generate a real LiveKit JWT for an anonymous user in the room."""
|
||||
return utils.generate_token(
|
||||
room=str(room.id),
|
||||
user=AnonymousUser(),
|
||||
participant_id="anon-participant-id",
|
||||
)
|
||||
|
||||
|
||||
# ---
|
||||
# toggle-hand
|
||||
# ---
|
||||
|
||||
|
||||
def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
||||
"""Test successfully raising a participant's hand."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
||||
"""Test successfully lowering a participant's hand."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
||||
"""Test that raising a hand sets a non-empty ISO timestamp as the attribute."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].attributes["handRaisedAt"] != ""
|
||||
|
||||
|
||||
def test_toggle_hand_identity_derived_from_token(
|
||||
mock_livekit_client, room, token, user
|
||||
):
|
||||
"""Test that the participant identity is derived from the token, not supplied by the client."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].identity == str(user.sub)
|
||||
|
||||
|
||||
def test_toggle_hand_missing_raised_field(room, token):
|
||||
"""Test toggle hand with missing raised field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "raised" in response.data
|
||||
|
||||
|
||||
def test_toggle_hand_invalid_raised_field(room, token):
|
||||
"""Test toggle hand with non-boolean raised field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": "not-a-boolean"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_toggle_hand_forbidden_without_token(room):
|
||||
"""Test toggle hand returns 403 when no LiveKit token is provided."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(url, {"raised": True}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
||||
"""Test toggle hand returns 403 when the token is scoped to a different room."""
|
||||
wrong_room = RoomFactory()
|
||||
target_room = RoomFactory()
|
||||
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||
"""Test toggle hand when LiveKit API raises TwirpError."""
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="Internal server error", code="unknown", status=500
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to update participant hand state"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_toggle_hand_raise_success_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
"""Test successfully raising hand as an anonymous participant."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_toggle_hand_lower_success_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
"""Test successfully lowering hand as an anonymous participant."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
|
||||
|
||||
|
||||
def test_toggle_hand_identity_derived_from_token_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
"""Test that identity is derived from participant_id for anonymous users."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].identity == "anon-participant-id"
|
||||
|
||||
|
||||
# ---
|
||||
# rename
|
||||
# ---
|
||||
|
||||
|
||||
def test_rename_participant_success(mock_livekit_client, room, token):
|
||||
"""Test successfully renaming a participant."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
||||
"""Test that rename passes the correct name to LiveKit."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].name == "Jane Doe"
|
||||
|
||||
|
||||
def test_rename_participant_uses_identity_from_token(
|
||||
mock_livekit_client, room, token, user
|
||||
):
|
||||
"""Test that rename derives participant identity from the LiveKit token, not the request."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].identity == str(user.sub)
|
||||
|
||||
|
||||
def test_rename_participant_empty_name(room, token):
|
||||
"""Test rename with an empty name returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "name" in response.data
|
||||
|
||||
|
||||
def test_rename_participant_missing_name(room, token):
|
||||
"""Test rename with missing name field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "name" in response.data
|
||||
|
||||
|
||||
def test_rename_participant_name_too_long(room, token):
|
||||
"""Test rename with a name exceeding 255 characters returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "name" in response.data
|
||||
|
||||
|
||||
def test_rename_participant_forbidden_without_token(room):
|
||||
"""Test rename returns 403 when no LiveKit token is provided."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(url, {"name": "John Doe"}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_rename_participant_forbidden_token_for_wrong_room(user):
|
||||
"""Test rename returns 403 when the token is scoped to a different room."""
|
||||
wrong_room = RoomFactory()
|
||||
target_room = RoomFactory()
|
||||
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||
"""Test rename when LiveKit API raises TwirpError."""
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="Internal server error", code="unknown", status=500
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to rename participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_success_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
"""Test successfully renaming an anonymous participant."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_uses_identity_from_token_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
"""Test that rename derives identity from participant_id for anonymous users."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].identity == "anon-participant-id"
|
||||
|
||||
|
||||
def test_rename_participant_sets_correct_name_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
"""Test that rename passes the correct name to LiveKit for anonymous users."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].name == "Guest User"
|
||||
|
||||
|
||||
def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_token):
|
||||
"""Test rename returns 403 when anonymous token is scoped to a different room."""
|
||||
target_room = RoomFactory()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ---
|
||||
# expired / malformed / missing room — shared cases
|
||||
# ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def expired_token(room, user):
|
||||
"""Generate a LiveKit JWT frozen in the past, guaranteed to be expired."""
|
||||
return utils.generate_token(room=str(room.id), user=user)
|
||||
|
||||
|
||||
def test_toggle_hand_expired_token(room, expired_token):
|
||||
"""Test toggle hand returns 403 when the LiveKit token is expired."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_rename_participant_expired_token(room, expired_token):
|
||||
"""Test rename returns 403 when the LiveKit token is expired."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_toggle_hand_malformed_token(room):
|
||||
"""Test toggle hand returns 403 when the LiveKit token is malformed."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_toggle_hand_room_not_found(user):
|
||||
"""Test toggle hand returns 404 when the room does not exist."""
|
||||
non_existent_room_id = uuid4()
|
||||
token = utils.generate_token(room=str(non_existent_room_id), user=user)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
||||
"""Test toggle hand returns 404 when the participant no longer exists in the room."""
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_malformed_token(room):
|
||||
"""Test rename returns 403 when the LiveKit token is malformed."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_rename_participant_room_not_found(user):
|
||||
"""Test rename returns 404 when the room does not exist."""
|
||||
non_existent_room_id = uuid4()
|
||||
token = utils.generate_token(room=str(non_existent_room_id), user=user)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_rename_participant_not_found(mock_livekit_client, room, token):
|
||||
"""Test rename returns 404 when the participant no longer exists in the room."""
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"""
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory(
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -140,16 +140,18 @@ def test_start_recording_worker_error(
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to start for room {room.slug}"
|
||||
}
|
||||
|
||||
# Recording object should be created even if worker fails
|
||||
# Recording object should be created even if worker fails, and moved out
|
||||
# of the unique-constraint window so the room is not locked.
|
||||
assert Recording.objects.count() == 1
|
||||
recording = Recording.objects.first()
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
assert recording.status == "failed_to_start"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
@@ -158,6 +160,72 @@ def test_start_recording_worker_error(
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["active", "initiated"],
|
||||
)
|
||||
def test_start_recording_conflict_when_already_in_progress(
|
||||
status, mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should return 409 when a second start is attempted while a recording is already active."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Pre-existing active recording for the same room.
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": f"A recording is already in progress for room {room.slug}"
|
||||
}
|
||||
# No new recording row, no access row leaked from the rolled-back transaction.
|
||||
assert Recording.objects.count() == 1
|
||||
assert Recording.objects.first().accesses.count() == 0
|
||||
mock_worker_manager.start.assert_not_called()
|
||||
|
||||
|
||||
def test_start_recording_after_worker_failure_unblocks_room(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should allow a new recording when the previous recording failed."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_worker_manager.start = mock.Mock(
|
||||
side_effect=[RecordingStartError("boom"), None]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert first.status_code == 502
|
||||
|
||||
second = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert Recording.objects.count() == 2
|
||||
|
||||
|
||||
def test_start_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
@@ -277,6 +345,7 @@ def test_start_recording_options_transcribe_valid_true(
|
||||
):
|
||||
"""Should accept transcribe with any valid pydantic true values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
@@ -487,6 +556,93 @@ def test_start_recording_options_original_mode_omitted(
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_calls_metadata_collector_start(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should call MetadataCollectorService.start when conditions are met."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.viewsets.MetadataCollectorService"
|
||||
) as mock_collector_class:
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"transcribe": True, "collect_metadata": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
mock_collector.start.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata_enabled,options",
|
||||
[
|
||||
# Metadata collector disabled, regardless of transcribe option
|
||||
(False, {"transcribe": True}),
|
||||
(False, {"transcribe": False}),
|
||||
(False, None),
|
||||
# Metadata collector enabled, but transcribe is False or missing
|
||||
(True, {"transcribe": False}),
|
||||
(True, None),
|
||||
# Metadata collector enabled, transcribe True, but collect_metadata explicitly False
|
||||
(True, {"transcribe": True, "collect_metadata": False}),
|
||||
],
|
||||
)
|
||||
def test_start_recording_does_not_call_metadata_collector_start_when_conditions_not_met(
|
||||
settings,
|
||||
mock_worker_service_factory,
|
||||
mock_worker_manager,
|
||||
metadata_enabled,
|
||||
options,
|
||||
):
|
||||
"""Should not call MetadataCollectorService.start when conditions are not met."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
payload = {"mode": "screen_recording"}
|
||||
if options is not None:
|
||||
payload["options"] = options
|
||||
|
||||
with mock.patch(
|
||||
"core.api.viewsets.MetadataCollectorService"
|
||||
) as mock_collector_class:
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_collector.start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
|
||||
def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
"""Should reject invalid recording mode values for original_mode."""
|
||||
|
||||
@@ -108,7 +108,9 @@ def test_start_subtitle_invalid_token():
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION="Bearer invalid-token",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -125,7 +127,8 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -144,7 +147,8 @@ def test_start_subtitle_valid_token(
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -168,12 +172,13 @@ def test_start_subtitle_twirp_error(
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
msg="Internal server error", code="unknown", status=500
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
@@ -192,7 +197,8 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -205,14 +211,15 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
||||
"""Test that tokens signed with incorrect signature are rejected."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret-padded-to-32-bytes!!"
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_api_rooms_update_members():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -104,7 +104,98 @@ def test_api_rooms_update_administrators():
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"the_key": "the_value"}
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration",
|
||||
[
|
||||
{},
|
||||
{"can_publish_sources": ["camera", "microphone"]},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
"camera",
|
||||
"microphone",
|
||||
"screen_share",
|
||||
"screen_share_audio",
|
||||
]
|
||||
},
|
||||
{"can_publish_sources": []},
|
||||
{"can_publish_sources": None},
|
||||
],
|
||||
)
|
||||
def test_api_rooms_update_configuration_valid(configuration):
|
||||
"""Administrators should be allowed to set valid configurations."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": configuration},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == configuration
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_extra_keys_rejected():
|
||||
"""Extra keys in configuration should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"configuration": {
|
||||
"can_publish_sources": ["camera"],
|
||||
"arbitrary_key": "value",
|
||||
}
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"])
|
||||
def test_api_rooms_update_configuration_invalid_source_value(invalid_source):
|
||||
"""Invalid source values should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": [invalid_source]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_wrong_type():
|
||||
"""Configuration values with wrong types should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": "camera"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
|
||||
@@ -47,7 +47,7 @@ def mock_livekit_config(settings):
|
||||
"""Mock LiveKit configuration."""
|
||||
settings.LIVEKIT_CONFIGURATION = {
|
||||
"api_key": "test_api_key",
|
||||
"api_secret": "test_api_secret",
|
||||
"api_secret": "test_api_secret_padded_to_32bytes!",
|
||||
"url": "https://test-livekit.example.com/",
|
||||
}
|
||||
return settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
"""
|
||||
Tests for JWT token service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core.services.jwt_token import (
|
||||
JwtTokenService,
|
||||
TokenDecodeError,
|
||||
TokenExpiredError,
|
||||
TokenInvalidError,
|
||||
)
|
||||
|
||||
# -- Fixtures --
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_service():
|
||||
"""Create a JWT token service for testing."""
|
||||
return JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="test-issuer",
|
||||
audience="test-audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
"""Create a mock user with a string ID."""
|
||||
user = mock.Mock()
|
||||
user.id = "test-user-id"
|
||||
return user
|
||||
|
||||
|
||||
# -- __init__ / Configuration --
|
||||
|
||||
|
||||
def test_init_missing_secret_key():
|
||||
"""Missing secret key should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
||||
JwtTokenService(
|
||||
secret_key="",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
|
||||
def test_init_none_secret_key():
|
||||
"""None secret key should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
||||
JwtTokenService(
|
||||
secret_key=None,
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
|
||||
def test_init_missing_algorithm():
|
||||
"""Missing algorithm should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
||||
JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
|
||||
def test_init_none_algorithm():
|
||||
"""None algorithm should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
||||
JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm=None,
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
|
||||
def test_init_missing_token_type():
|
||||
"""Missing token type should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
||||
JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="",
|
||||
)
|
||||
|
||||
|
||||
def test_init_none_token_type():
|
||||
"""None token type should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
||||
JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type=None,
|
||||
)
|
||||
|
||||
|
||||
def test_init_none_expiration_seconds():
|
||||
"""None expiration seconds should raise ImproperlyConfigured."""
|
||||
with pytest.raises(ImproperlyConfigured, match="Expiration's seconds is required"):
|
||||
JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=None,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
|
||||
def test_init_zero_expiration_seconds_is_accepted():
|
||||
"""expiration_seconds=0 is falsy but should be accepted — token expires immediately."""
|
||||
service = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=0,
|
||||
token_type="Bearer",
|
||||
)
|
||||
assert service._expiration_seconds == 0
|
||||
|
||||
|
||||
def test_init_stores_config_correctly():
|
||||
"""All config values should be stored correctly on the instance."""
|
||||
service = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="my-issuer",
|
||||
audience="my-audience",
|
||||
expiration_seconds=1800,
|
||||
token_type="Bearer",
|
||||
)
|
||||
assert service._key == "test-secret-padded-to-32-bytes!!"
|
||||
assert service._algorithm == "HS256"
|
||||
assert service._issuer == "my-issuer"
|
||||
assert service._audience == "my-audience"
|
||||
assert service._expiration_seconds == 1800
|
||||
assert service._token_type == "Bearer"
|
||||
|
||||
|
||||
# -- generate_jwt / Return shape --
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_always_returns_required_keys(jwt_service, mock_user):
|
||||
"""Response always contains access_token, token_type, and expires_in."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||
|
||||
assert "access_token" in result
|
||||
assert "token_type" in result
|
||||
assert "expires_in" in result
|
||||
assert result["token_type"] == "Bearer"
|
||||
assert result["expires_in"] == 3600
|
||||
assert isinstance(result["access_token"], str)
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_scope_present_when_provided(jwt_service, mock_user):
|
||||
"""scope key should be present in response when scope is provided."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read write")
|
||||
|
||||
assert result["scope"] == "read write"
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_scope_absent_when_empty(jwt_service, mock_user):
|
||||
"""scope key should be absent from response when scope is empty."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="")
|
||||
|
||||
assert "scope" not in result
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_scope_absent_when_none(jwt_service, mock_user):
|
||||
"""scope key should be absent from response when scope is None."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope=None)
|
||||
|
||||
assert "scope" not in result
|
||||
|
||||
|
||||
# -- generate_jwt / Payload correctness --
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_payload_contains_required_claims(jwt_service, mock_user):
|
||||
"""Payload should always contain iat, exp, and user_id."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["iat"] == 1673784000
|
||||
assert payload["exp"] == 1673787600
|
||||
assert payload["user_id"] == "test-user-id"
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_exp_is_now_plus_expiration_seconds(mock_user):
|
||||
"""exp should equal iat + expiration_seconds exactly."""
|
||||
service = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=900,
|
||||
token_type="Bearer",
|
||||
)
|
||||
result = service.generate_jwt(mock_user, scope="read")
|
||||
payload = service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["exp"] - payload["iat"] == 900
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_iss_included_when_set(jwt_service, mock_user):
|
||||
"""iss should be present in payload when issuer is non-empty."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["iss"] == "test-issuer"
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_aud_included_when_set(jwt_service, mock_user):
|
||||
"""aud should be present in payload when audience is non-empty."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["aud"] == "test-audience"
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_iss_absent_when_empty(mock_user):
|
||||
"""iss should be absent from payload when issuer is empty string."""
|
||||
|
||||
service = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="",
|
||||
audience="",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
result = service.generate_jwt(mock_user, scope="read")
|
||||
payload = pyjwt.decode(
|
||||
result["access_token"],
|
||||
"test-secret-padded-to-32-bytes!!",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
assert "iss" not in payload
|
||||
assert "aud" not in payload
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_iss_absent_when_none(mock_user):
|
||||
"""iss should be absent from payload when issuer is None."""
|
||||
|
||||
service = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer=None,
|
||||
audience=None,
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
result = service.generate_jwt(mock_user, scope="read")
|
||||
payload = pyjwt.decode(
|
||||
result["access_token"],
|
||||
"test-secret-padded-to-32-bytes!!",
|
||||
algorithms=["HS256"],
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
assert "iss" not in payload
|
||||
assert "aud" not in payload
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_scope_absent_from_payload_when_empty(jwt_service, mock_user):
|
||||
"""scope should be absent from payload when not provided."""
|
||||
|
||||
result = jwt_service.generate_jwt(mock_user, scope="")
|
||||
payload = pyjwt.decode(
|
||||
result["access_token"],
|
||||
"test-secret-padded-to-32-bytes!!",
|
||||
algorithms=["HS256"],
|
||||
issuer="test-issuer",
|
||||
audience="test-audience",
|
||||
)
|
||||
|
||||
assert "scope" not in payload
|
||||
|
||||
|
||||
# -- generate_jwt / extra_payload handling --
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_extra_payload_none_does_not_crash(jwt_service, mock_user):
|
||||
"""extra_payload=None should not crash and produce a valid token."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload=None)
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["user_id"] == "test-user-id"
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_extra_payload_non_colliding_keys_preserved(
|
||||
jwt_service, mock_user
|
||||
):
|
||||
"""Non-colliding extra_payload keys should appear in decoded token."""
|
||||
result = jwt_service.generate_jwt(
|
||||
mock_user,
|
||||
scope="read",
|
||||
extra_payload={"client_id": "my-app", "delegated": True},
|
||||
)
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["client_id"] == "my-app"
|
||||
assert payload["delegated"] is True
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_extra_payload_colliding_iat_overwritten(jwt_service, mock_user):
|
||||
"""iat in extra_payload should be overwritten by the service."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload={"iat": 0})
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["iat"] == 1673784000
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_extra_payload_colliding_exp_overwritten(jwt_service, mock_user):
|
||||
"""exp in extra_payload should be overwritten by the service."""
|
||||
result = jwt_service.generate_jwt(
|
||||
mock_user, scope="read", extra_payload={"exp": 9999999999}
|
||||
)
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["exp"] == 1673787600
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_extra_payload_colliding_user_id_overwritten(
|
||||
jwt_service, mock_user
|
||||
):
|
||||
"""user_id in extra_payload should be overwritten by the service."""
|
||||
result = jwt_service.generate_jwt(
|
||||
mock_user, scope="read", extra_payload={"user_id": "hacked"}
|
||||
)
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["user_id"] == "test-user-id"
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_extra_payload_not_mutated(jwt_service, mock_user):
|
||||
"""generate_jwt should not mutate the original extra_payload dict."""
|
||||
extra = {"client_id": "my-app"}
|
||||
jwt_service.generate_jwt(mock_user, scope="read", extra_payload=extra)
|
||||
|
||||
assert extra == {"client_id": "my-app"}
|
||||
|
||||
|
||||
# -- generate_jwt / user.id casting --
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_generate_jwt_user_id_cast_from_uuid(jwt_service):
|
||||
"""user.id as UUID should be cast to str in payload."""
|
||||
user = mock.Mock()
|
||||
user.id = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||||
result = jwt_service.generate_jwt(user, scope="read")
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["user_id"] == "12345678-1234-5678-1234-567812345678"
|
||||
|
||||
|
||||
# -- decode_jwt / Happy path --
|
||||
|
||||
|
||||
def test_decode_jwt_roundtrip(jwt_service, mock_user):
|
||||
"""Valid token should decode to correct payload."""
|
||||
with freeze_time("2023-01-15 12:00:00"):
|
||||
result = jwt_service.generate_jwt(
|
||||
mock_user, scope="read", extra_payload={"client_id": "my-app"}
|
||||
)
|
||||
|
||||
with freeze_time("2023-01-15 12:30:00"):
|
||||
payload = jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
assert payload["user_id"] == "test-user-id"
|
||||
assert payload["scope"] == "read"
|
||||
assert payload["client_id"] == "my-app"
|
||||
assert payload["iss"] == "test-issuer"
|
||||
assert payload["aud"] == "test-audience"
|
||||
|
||||
|
||||
# -- decode_jwt / Error mapping --
|
||||
|
||||
|
||||
def test_decode_jwt_expired_raises_token_expired_error(jwt_service, mock_user):
|
||||
"""Expired token should raise TokenExpiredError."""
|
||||
with freeze_time("2023-01-15 12:00:00"):
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||
|
||||
with freeze_time("2099-01-01 00:00:00"):
|
||||
with pytest.raises(TokenExpiredError):
|
||||
jwt_service.decode_jwt(result["access_token"])
|
||||
|
||||
|
||||
def test_decode_jwt_wrong_issuer_raises_token_invalid_error(mock_user):
|
||||
"""Token with wrong issuer should raise TokenInvalidError."""
|
||||
service_a = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer-a",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
service_b = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer-b",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
result = service_a.generate_jwt(mock_user, scope="read")
|
||||
|
||||
with pytest.raises(TokenInvalidError):
|
||||
service_b.decode_jwt(result["access_token"])
|
||||
|
||||
|
||||
def test_decode_jwt_wrong_audience_raises_token_invalid_error(mock_user):
|
||||
"""Token with wrong audience should raise TokenInvalidError."""
|
||||
service_a = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience-a",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
service_b = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience-b",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
result = service_a.generate_jwt(mock_user, scope="read")
|
||||
|
||||
with pytest.raises(TokenInvalidError):
|
||||
service_b.decode_jwt(result["access_token"])
|
||||
|
||||
|
||||
def test_decode_jwt_tampered_signature_raises_token_decode_error(
|
||||
jwt_service, mock_user
|
||||
):
|
||||
"""Token with tampered signature should raise TokenDecodeError."""
|
||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||
header, payload, _ = result["access_token"].split(".")
|
||||
tampered_token = f"{header}.{payload}.invalidsignature"
|
||||
|
||||
with pytest.raises(TokenDecodeError):
|
||||
jwt_service.decode_jwt(tampered_token)
|
||||
|
||||
|
||||
def test_decode_jwt_garbage_string_raises_token_decode_error(jwt_service):
|
||||
"""Garbage string should raise TokenDecodeError."""
|
||||
with pytest.raises(TokenDecodeError):
|
||||
jwt_service.decode_jwt("this.is.not.a.valid.token")
|
||||
|
||||
|
||||
def test_decode_jwt_empty_string_raises_token_decode_error(jwt_service):
|
||||
"""Empty string should raise TokenDecodeError."""
|
||||
with pytest.raises(TokenDecodeError):
|
||||
jwt_service.decode_jwt("")
|
||||
|
||||
|
||||
def test_decode_jwt_none_raises_token_decode_error(jwt_service):
|
||||
"""None should raise TokenDecodeError."""
|
||||
with pytest.raises(TokenDecodeError):
|
||||
jwt_service.decode_jwt(None)
|
||||
|
||||
|
||||
def test_algorithm_mismatch_raises_token_decode_error(mock_user):
|
||||
"""Token encoded with HS256 decoded expecting RS256 should raise TokenDecodeError."""
|
||||
service_hs256 = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="HS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
service_rs256 = JwtTokenService(
|
||||
secret_key="test-secret-padded-to-32-bytes!!",
|
||||
algorithm="RS256",
|
||||
issuer="issuer",
|
||||
audience="audience",
|
||||
expiration_seconds=3600,
|
||||
token_type="Bearer",
|
||||
)
|
||||
result = service_hs256.generate_jwt(mock_user, scope="read")
|
||||
|
||||
with pytest.raises(TokenDecodeError):
|
||||
service_rs256.decode_jwt(result["access_token"])
|
||||
@@ -269,6 +269,63 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
options={"metadata_collector_dispatch_id": "dispatch-123"},
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_collector.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata_enabled,options",
|
||||
[
|
||||
(True, {}),
|
||||
(False, {}),
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should not call MetadataCollectorService.stop when it does not exist."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
options=options,
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
mock_collector = mock.Mock()
|
||||
mock_collector_class.return_value = mock_collector
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_collector.stop.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
|
||||
@@ -72,7 +72,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
@@ -177,7 +177,7 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
@@ -270,7 +270,7 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
||||
if call_count == 0:
|
||||
call_count += 1
|
||||
return None
|
||||
raise TwirpError(msg="Deletion failed", code=500, status=500)
|
||||
raise TwirpError(msg="Deletion failed", code="unknown", status=500)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=delete_side_effect
|
||||
@@ -294,7 +294,7 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
|
||||
@@ -22,6 +22,28 @@ from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, Us
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_addons_test_token(user, scopes, **overrides):
|
||||
"""Generate a valid JWT token signed with the addons secret for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.ADDONS_TOKEN_TTL),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
}
|
||||
payload.update(overrides)
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def generate_test_token(user, scopes):
|
||||
"""Generate a valid JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -123,6 +145,25 @@ def test_api_rooms_list_with_expired_token(settings):
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_list_with_application_disabled(mock_rs_authenticate, settings):
|
||||
"""Listing rooms should return 401 when application is disabled."""
|
||||
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Generate expired token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
"""Listing rooms with invalid resource server token should return 400."""
|
||||
@@ -713,7 +754,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
"invalid-private-key",
|
||||
"invalid-private-key-padded-to-32b!",
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
@@ -931,6 +972,11 @@ def test_api_rooms_token_inactive_application(settings):
|
||||
assert "application is disabled." in str(response.data).lower()
|
||||
|
||||
|
||||
# ==============================
|
||||
# Resource Server
|
||||
# ==============================
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_creates_user_on_first_authentication(settings):
|
||||
"""New user should be created during first authentication.
|
||||
@@ -1106,6 +1152,64 @@ def test_resource_server_authentication_successful(settings):
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_authentication_successful_when_application_disabled(settings):
|
||||
"""Resource server should keep working when the application auth backend is disabled."""
|
||||
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
other_user = UserFactory()
|
||||
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room_user_accesses = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[user]
|
||||
)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
|
||||
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "core.external_api.authentication.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
expected_ids = {str(room_user_accesses.id)}
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
"""Requests should be denied when the token lacks required scopes.
|
||||
@@ -1147,3 +1251,245 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ==============================
|
||||
# Addons
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_addons_token():
|
||||
"""Listing rooms with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_with_valid_addons_token():
|
||||
"""Retrieving a room with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_create_with_valid_addons_token():
|
||||
"""Creating a room with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_inactive_user():
|
||||
"""Addons token for an inactive user should return 401."""
|
||||
user = UserFactory(is_active=False)
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_expired(settings):
|
||||
"""Listing rooms with an expired addons token should return 401."""
|
||||
settings.ADDONS_TOKEN_TTL = 0
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_missing_user_id(settings):
|
||||
"""Addons token without user_id should be rejected."""
|
||||
|
||||
# Re-encode without user_id
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": "rooms:list",
|
||||
# no user_id
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_invalid_audience(settings):
|
||||
"""Addons token with an invalid audience should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": "invalid-audience",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"user_id": str(user.id),
|
||||
"scope": "rooms:list",
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_unknown_user(settings):
|
||||
"""Addons token for an unknown user should be rejected."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.ADDONS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user not found." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_missing_scope():
|
||||
"""Addons token without required scope should return 403."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:list"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_addons_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
"""Addons token signed with a wrong key should defer to the next authentication."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.ADDONS_TOKEN_ISSUER,
|
||||
"aud": settings.ADDONS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"user_id": str(user.id),
|
||||
"scope": "rooms:list",
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
"invalid-private-key-padded-to-32b!",
|
||||
algorithm=settings.ADDONS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_addons_disabled_defers_to_next_backend(
|
||||
mock_rs_authenticate, settings
|
||||
):
|
||||
"""When ADDONS_ENABLED is False, a valid addons token should defer to the next backend."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_addons_disabled_does_not_break_application_auth(settings):
|
||||
"""Disabling addons auth should not affect ApplicationJWTAuthentication."""
|
||||
settings.ADDONS_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Use the existing application token helper — that backend should still work
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
@@ -19,6 +19,35 @@ from core.models import ApplicationScope, User
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_applications_generate_token_application_disabled(settings):
|
||||
"""When APPLICATION_ENABLED is False, the endpoint should return 404."""
|
||||
settings.APPLICATION_ENABLED = False
|
||||
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
UserFactory(email="User.Family@example.com")
|
||||
|
||||
@@ -4,8 +4,9 @@ from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||
|
||||
from core.addons import viewsets as addons_viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
@@ -18,15 +19,19 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
router.register(
|
||||
"addons/sessions",
|
||||
addons_viewsets.SessionViewSet,
|
||||
basename="addons_sessions",
|
||||
)
|
||||
|
||||
# - External API
|
||||
external_router = DefaultRouter()
|
||||
external_router = SimpleRouter()
|
||||
external_router.register(
|
||||
"application",
|
||||
external_viewsets.ApplicationViewSet,
|
||||
basename="external_application",
|
||||
)
|
||||
|
||||
external_router.register(
|
||||
"rooms",
|
||||
external_viewsets.RoomViewSet,
|
||||
|
||||
@@ -96,7 +96,7 @@ def generate_token(
|
||||
room=room,
|
||||
room_join=True,
|
||||
room_admin=is_admin_or_owner,
|
||||
can_update_own_metadata=True,
|
||||
can_update_own_metadata=False,
|
||||
can_publish=bool(sources),
|
||||
can_publish_sources=sources,
|
||||
can_subscribe=True,
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -94,11 +94,11 @@ msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -92,11 +92,11 @@ msgstr "Creator is me"
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "You have reached the maximum number of files for this type."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -95,11 +95,11 @@ msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -93,11 +93,11 @@ msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/api/serializers.py:516
|
||||
#: core/api/serializers.py:509
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
#: core/api/viewsets.py:1090
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||
|
||||
|
||||
@@ -808,7 +808,20 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Metadata collector settings
|
||||
METADATA_COLLECTOR_ENABLED = values.BooleanValue(
|
||||
False, environ_name="METADATA_COLLECTOR_ENABLED", environ_prefix=None
|
||||
)
|
||||
METADATA_COLLECTOR_AGENT_NAME = values.Value(
|
||||
"metadata-collector",
|
||||
environ_name="METADATA_COLLECTOR_AGENT_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
|
||||
)
|
||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||
40,
|
||||
environ_name="APPLICATION_CLIENT_ID_LENGTH",
|
||||
@@ -861,6 +874,88 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Addons
|
||||
ADDONS_ENABLED = values.BooleanValue(
|
||||
False,
|
||||
environ_name="ADDONS_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SESSION_TTL = values.PositiveIntegerValue(
|
||||
3600,
|
||||
environ_name="ADDONS_SESSION_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TRANSIT_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
120,
|
||||
environ_name="ADDONS_TRANSIT_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_CSRF_SECRET = SecretFileValue(
|
||||
None,
|
||||
environ_name="ADDONS_CSRF_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_CACHE_PREFIX_SESSION = values.Value(
|
||||
"sid",
|
||||
environ_name="ADDONS_CACHE_PREFIX_SESSION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_CACHE_PREFIX_TRANSIT = values.Value(
|
||||
"transit",
|
||||
environ_name="ADDONS_CACHE_PREFIX_TRANSIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_AUDIENCE = values.Value(
|
||||
"addons",
|
||||
environ_name="ADDONS_TOKEN_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="ADDONS_TOKEN_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
7200,
|
||||
environ_name="ADDONS_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="ADDONS_TOKEN_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="ADDONS_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||
None,
|
||||
environ_name="ADDONS_TOKEN_SECRET_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_TOKEN_SCOPE = values.Value(
|
||||
"rooms:create",
|
||||
environ_name="ADDONS_TOKEN_SCOPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_RANDOM_TOKEN_BYTE_LENGTH = values.PositiveIntegerValue(
|
||||
60,
|
||||
environ_name="ADDONS_RANDOM_TOKEN_BYTE_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_SESSION_ID_COOKIE = values.Value(
|
||||
"addonsSid",
|
||||
environ_name="ADDONS_SESSION_ID_COOKIE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ADDONS_PENDING_SESSION_KEY = values.Value(
|
||||
"addons_sid",
|
||||
environ_name="ADDONS_PENDING_SESSION_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -981,12 +1076,23 @@ class Test(Base):
|
||||
USE_SWAGGER = True
|
||||
EXTERNAL_API_ENABLED = True
|
||||
|
||||
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
"api_key": "devkey-padded-for-minimum-len!-livekit",
|
||||
"api_secret": "secret-key-padded-for-minimum-len!-livekit",
|
||||
"url": "http://127.0.0.1.nip.io:7880",
|
||||
}
|
||||
|
||||
APPLICATION_ENABLED = True
|
||||
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
|
||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
FILE_UPLOAD_ENABLED = True
|
||||
|
||||
ADDONS_ENABLED = True
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.12.0"
|
||||
version = "1.15.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -33,14 +33,14 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.25",
|
||||
"django-lasuite[all]==0.0.26",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.12",
|
||||
"django==5.2.13",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
@@ -55,12 +55,12 @@ dependencies = [
|
||||
"PyJWT==2.12.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.5",
|
||||
"requests==2.33.0",
|
||||
"sentry-sdk==2.54.0",
|
||||
"whitenoise==6.12.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.3",
|
||||
"aiohttp==3.13.4",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -81,7 +81,7 @@ dev = [
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.3"
|
||||
version = "3.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -24,59 +24,59 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -141,11 +141,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -173,16 +173,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.73"
|
||||
version = "1.42.89"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/e6be943efa9051bd15c2ee14077c2b10d6e27c9e9385fc43a03a5c4ed8b5/botocore-1.42.89.tar.gz", hash = "sha256:95ac52f472dad29942f3088b278ab493044516c16dbf9133c975af16527baa99", size = 15206290, upload-time = "2026-04-13T19:36:02.321Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -306,55 +306,71 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.5"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
version = "8.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -405,124 +421,124 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
version = "7.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -557,16 +573,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.12"
|
||||
version = "5.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -633,7 +649,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-lasuite"
|
||||
version = "0.0.25"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
@@ -644,9 +660,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/d7/6c022ff6b2e5c6f7226005c5f62920763ddb9ae2d4948b6af6d206314036/django_lasuite-0.0.26.tar.gz", hash = "sha256:b4ffb15d3e56e366d71b527195d26198e2d26e28cc1ab1c11aefdc581db9c02b", size = 34952, upload-time = "2026-04-03T14:02:33.957Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ab/18a1505061b536616ea4b9f75154042116ca4f1a9f64467f5a3549c686d2/django_lasuite-0.0.26-py3-none-any.whl", hash = "sha256:ba812e643eae3f55a50a69bf09eaa3b8ffa4fe9dd3296b8e27b4f7bbc25e3400", size = 54158, upload-time = "2026-04-03T14:02:32.691Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -818,14 +834,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.8.0"
|
||||
version = "40.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3b/c6348f1e285e75b069085b18110a4e6325b763a5d35d5e204356fc7c20b3/faker-40.8.0-py3-none-any.whl", hash = "sha256:eb21bdba18f7a8375382eb94fb436fce07046893dc94cb20817d28deb0c3d579", size = 1989124, upload-time = "2026-03-04T16:18:46.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1039,14 +1055,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.3"
|
||||
version = "1.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/90/b8cc8635c4ce2e5e8104bf26ef147f6e599478f6329107283cdc53aae97f/joserfc-1.6.3.tar.gz", hash = "sha256:c00c2830db969b836cba197e830e738dd9dda0955f1794e55d3c636f17f5c9a6", size = 229090, upload-time = "2026-02-25T15:33:38.167Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4f/124b3301067b752f44f292f0b9a74e837dd75ff863ee39500a082fc4c733/joserfc-1.6.3-py3-none-any.whl", hash = "sha256:6beab3635358cbc565cb94fb4c53d0557e6d10a15b933e2134939351590bda9a", size = 70465, upload-time = "2026-02-25T15:33:36.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1114,15 +1130,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "livekit-protocol"
|
||||
version = "1.1.2"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
{ name = "types-protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/6c/f5f7cb226441b3a357c2ea5444899b133dd13a5875894c6a9cd52fc5aa74/livekit_protocol-1.1.2.tar.gz", hash = "sha256:4550bf78fb9d365f19ea9875e565d86a2fb798854c8bd2e9100d7f7640dd9072", size = 79620, upload-time = "2026-01-20T01:27:23.437Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/49/6137f28bfdc6c161e4b3327a05e103cda30bf521151736dcbf8bbdb2a5ca/livekit_protocol-1.1.5.tar.gz", hash = "sha256:54af234c15819a6f8e2b51511325fcfb181fed49a04547a7dfcd9b67ccd03767", size = 93740, upload-time = "2026-04-08T21:20:00.937Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f9/40e81d1b126d79a00b4a8a472a4f7c655b0a7736bb4d08f936be550b3bd8/livekit_protocol-1.1.2-py3-none-any.whl", hash = "sha256:8a26d592a87f5f70fee23aa88e47490727158ee8799c82742585aa8f73b160c5", size = 98854, upload-time = "2026-01-20T01:27:22.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/cd/903f8a11a49332933ae2d5c34baa22ee2cb94f4426e25a3eff6d598ffb04/livekit_protocol-1.1.5-py3-none-any.whl", hash = "sha256:7d517ac2bab69a2b889f94ea65ae74be4f9db251d614513bbcf1e3d333008720", size = 114852, upload-time = "2026-04-08T21:19:59.341Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1157,7 +1173,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.12.0"
|
||||
version = "1.15.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1221,18 +1237,18 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = "==3.13.3" },
|
||||
{ name = "aiohttp", specifier = "==3.13.4" },
|
||||
{ name = "boto3", specifier = "==1.42.68" },
|
||||
{ name = "brevo-python", specifier = "==1.2.0" },
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||
{ name = "django", specifier = "==5.2.12" },
|
||||
{ name = "django", specifier = "==5.2.13" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==8.2.0" },
|
||||
{ name = "django-filter", specifier = "==25.2" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.26" },
|
||||
{ name = "django-parler", specifier = "==2.3" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.5.4" },
|
||||
{ name = "django-redis", specifier = "==6.0.0" },
|
||||
@@ -1255,7 +1271,7 @@ requires-dist = [
|
||||
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
||||
{ name = "python-magic", specifier = "==0.4.27" },
|
||||
{ name = "redis", specifier = "==5.2.1" },
|
||||
{ name = "requests", specifier = "==2.32.5" },
|
||||
{ name = "requests", specifier = "==2.33.0" },
|
||||
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||
]
|
||||
@@ -1270,7 +1286,7 @@ dev = [
|
||||
{ name = "pyfakefs", specifier = "==6.1.5" },
|
||||
{ name = "pylint", specifier = "<4.0.0" },
|
||||
{ name = "pylint-django", specifier = "==2.7.0" },
|
||||
{ name = "pytest", specifier = "==9.0.2" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "pytest-cov", specifier = "==7.0.0" },
|
||||
{ name = "pytest-django", specifier = "==4.12.0" },
|
||||
{ name = "pytest-icdiff", specifier = "==0.9" },
|
||||
@@ -1384,11 +1400,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/b8/48/b91a6f924a2abd9e8
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
version = "26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1414,69 +1430,69 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.4"
|
||||
version = "4.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1580,17 +1596,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "7.34.0"
|
||||
version = "7.34.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/00/04a2ab36b70a52d0356852979e08b44edde0435f2115dc66e25f2100f3ab/protobuf-7.34.0.tar.gz", hash = "sha256:3871a3df67c710aaf7bb8d214cc997342e63ceebd940c8c7fc65c9b3d697591a", size = 454726, upload-time = "2026-02-27T00:30:25.421Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/c4/6322ab5c8f279c4c358bc14eb8aefc0550b97222a39f04eb3c1af7a830fa/protobuf-7.34.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e329966799f2c271d5e05e236459fe1cbfdb8755aaa3b0914fa60947ddea408", size = 429248, upload-time = "2026-02-27T00:30:14.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/99/b029bbbc61e8937545da5b79aa405ab2d9cf307a728f8c9459ad60d7a481/protobuf-7.34.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:9d7a5005fb96f3c1e64f397f91500b0eb371b28da81296ae73a6b08a5b76cdd6", size = 325753, upload-time = "2026-02-27T00:30:17.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/79/09f02671eb75b251c5550a1c48e7b3d4b0623efd7c95a15a50f6f9fc1e2e/protobuf-7.34.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4a72a8ec94e7a9f7ef7fe818ed26d073305f347f8b3b5ba31e22f81fd85fca02", size = 340200, upload-time = "2026-02-27T00:30:18.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/57/89727baef7578897af5ed166735ceb315819f1c184da8c3441271dbcfde7/protobuf-7.34.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:964cf977e07f479c0697964e83deda72bcbc75c3badab506fb061b352d991b01", size = 324268, upload-time = "2026-02-27T00:30:20.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3e/38ff2ddee5cc946f575c9d8cc822e34bde205cf61acf8099ad88ef19d7d2/protobuf-7.34.0-cp310-abi3-win32.whl", hash = "sha256:f791ec509707a1d91bd02e07df157e75e4fb9fbdad12a81b7396201ec244e2e3", size = 426628, upload-time = "2026-02-27T00:30:21.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/71/7c32eaf34a61a1bae1b62a2ac4ffe09b8d1bb0cf93ad505f42040023db89/protobuf-7.34.0-cp310-abi3-win_amd64.whl", hash = "sha256:9f9079f1dde4e32342ecbd1c118d76367090d4aaa19da78230c38101c5b3dd40", size = 437901, upload-time = "2026-02-27T00:30:22.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/e7/14dc9366696dcb53a413449881743426ed289d687bcf3d5aee4726c32ebb/protobuf-7.34.0-py3-none-any.whl", hash = "sha256:e3b914dd77fa33fa06ab2baa97937746ab25695f389869afdf03e81f34e45dc7", size = 170716, upload-time = "2026-02-27T00:30:23.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1745,11 +1761,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1805,7 +1821,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1814,9 +1830,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1965,7 +1981,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1973,9 +1989,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2172,11 +2188,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-protobuf"
|
||||
version = "6.32.1.20260221"
|
||||
version = "7.34.1.20260408"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/b1/4521e68c2cc17703d80eb42796751345376dd4c706f84007ef5e7c707774/types_protobuf-7.34.1.20260408.tar.gz", hash = "sha256:e2c0a0430e08c75b52671a6f0035abfdcc791aad12af16274282de1b721758ab", size = 68835, upload-time = "2026-04-08T04:26:43.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/b5/0bc9874d89c58fb0ce851e150055ce732d254dbb10b06becbc7635d0d635/types_protobuf-7.34.1.20260408-py3-none-any.whl", hash = "sha256:ebbcd4e27b145aef6a59bc0cb6c013b3528151c1ba5e7f7337aeee355d276a5e", size = 86012, upload-time = "2026-04-08T04:26:42.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2214,11 +2230,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.3"
|
||||
version = "2026.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
|
||||
USER node
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
@@ -8,7 +10,7 @@ COPY ./src/frontend/package-lock.json ./package-lock.json
|
||||
RUN npm ci
|
||||
|
||||
COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/ .
|
||||
COPY --chown=node:node ./src/frontend/ .
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-deps AS meet
|
||||
@@ -17,6 +19,8 @@ WORKDIR /home/frontend
|
||||
|
||||
FROM frontend-deps AS meet-dev
|
||||
|
||||
USER node
|
||||
|
||||
WORKDIR /home/frontend
|
||||
|
||||
EXPOSE 8080
|
||||
@@ -35,15 +39,15 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 \
|
||||
libcrypto3 \
|
||||
libxml2>=2.12.7-r2 \
|
||||
libxslt>=1.1.39-r2 \
|
||||
libexpat>=2.7.2-r0 \
|
||||
libpng>=1.6.53-r0 \
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
&& apk del curl
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -5,12 +5,6 @@ server {
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
@@ -54,10 +54,10 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.1",
|
||||
"vite": "7.3.2",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
},
|
||||
@@ -822,9 +822,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1759,6 +1759,35 @@
|
||||
"ts-pattern": "5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/core/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/dev": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/dev/-/dev-1.8.2.tgz",
|
||||
@@ -1816,6 +1845,35 @@
|
||||
"ts-pattern": "5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/generator/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/is-valid-prop": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.2.tgz",
|
||||
@@ -1917,6 +1975,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/node/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/node/node_modules/prettier": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
@@ -1976,6 +2063,35 @@
|
||||
"postcss": "8.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/postcss/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-base": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-base/-/preset-base-1.8.2.tgz",
|
||||
@@ -7632,9 +7748,9 @@
|
||||
"integrity": "sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg=="
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9432,9 +9548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -11334,9 +11450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11424,9 +11540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -11441,9 +11557,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -11458,9 +11574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11475,9 +11591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11492,9 +11608,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11509,9 +11625,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11526,9 +11642,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11543,9 +11659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11560,9 +11676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -11577,9 +11693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11594,9 +11710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -11611,9 +11727,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -11628,9 +11744,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -11645,9 +11761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -11662,9 +11778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -11679,9 +11795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -11696,9 +11812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11713,9 +11829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11730,9 +11846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11747,9 +11863,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11764,9 +11880,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11781,9 +11897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11798,9 +11914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11815,9 +11931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -11832,9 +11948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11849,9 +11965,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -11862,32 +11978,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
@@ -11909,9 +12025,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -59,10 +59,10 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.1",
|
||||
"vite": "7.3.2",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +274,11 @@ const config: Config = {
|
||||
min: { value: 'min-content' },
|
||||
max: { value: 'max-content' },
|
||||
fit: { value: 'fit-content' },
|
||||
// room layout
|
||||
'room-side-panel': { value: '360px' },
|
||||
'room-side-panel-margin': { value: '1.5rem' },
|
||||
'room-control-bar': { value: '80px' },
|
||||
'room-reaction-toolbar-height': { value: '42px' },
|
||||
},
|
||||
spacing,
|
||||
}),
|
||||
|
||||
@@ -4,4 +4,6 @@ export enum FeatureFlags {
|
||||
faceLandmarks = 'face-landmarks',
|
||||
noiseReduction = 'noise-reduction',
|
||||
subtitles = 'subtitles',
|
||||
candidatePolling = 'candidate-polling',
|
||||
metadataCollector = 'metadata-collector',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const controlBarRegion = cva({
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: '1 1 33%',
|
||||
justifyContent: 'center',
|
||||
gap: '0.65rem',
|
||||
},
|
||||
variants: {
|
||||
mobile: {
|
||||
true: {
|
||||
justifyContent: 'space-between',
|
||||
width: '330px',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
mobile: false,
|
||||
},
|
||||
})
|
||||
|
||||
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
|
||||
RecipeVariantProps<typeof controlBarRegion>
|
||||
|
||||
export function ControlBarRegion({
|
||||
children,
|
||||
mobile,
|
||||
...props
|
||||
}: ControlBarRegionProps) {
|
||||
const { t } = useTranslation('rooms')
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t('controls.region')}
|
||||
className={controlBarRegion({ mobile })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||