mirror of
https://github.com/suitenumerique/meet.git
synced 2026-11-01 00:51:38 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a139fe58 | |||
| e1cc318d07 | |||
| fdc9f6ec9e | |||
| 8b06e0d2f9 | |||
| 84459eac35 | |||
| 20469913ab | |||
| 4ce6c96085 | |||
| 59aecb550f | |||
| a2c13cec46 | |||
| 2a9c99003c |
+7
-3
@@ -11,9 +11,13 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- ⬆️(summary) update ffmpeg to 8.1.3-r0
|
||||
- ✨(frontend) replace the Crisp chat with the La Suite support form
|
||||
- 🔒️(frontend) keep rooms out of search engine and AI indexes
|
||||
- ⬆️(backend) upgrade django-lasuite to v1.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(summary) protect signed URLs in models, source logs and analytics metadata
|
||||
- 🔒️(summary) mask secret validation inputs and sanitize upstream and task errors
|
||||
- 🔒️(summary) scrub sensitive Sentry event and breadcrumb content while retaining
|
||||
redacted frame locals and application DEBUG diagnostics
|
||||
|
||||
## [1.36.3] - 2026-10-23
|
||||
|
||||
|
||||
-12
@@ -16,18 +16,6 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Crisp support chat replaced by the La Suite support form
|
||||
|
||||
The Crisp chat is gone, the support button, now open the La Suite support form. They are hidden unless these `FRONTEND_SUPPORT` keys are all set.
|
||||
The former help links for recording/transcription are now redirecting to the adhoc page in the official documentation
|
||||
|
||||
### Pages hidden from search engines
|
||||
|
||||
Every page except `/` now carries a `noindex` tag and, with the provided nginx configurations, an `X-Robots-Tag` header to stay out of search indexes.
|
||||
A new `robots.txt` also blocks the main AI crawlers. If you maintain your own nginx configuration, copy the `location = /` and `location = /home.html` blocks from `src/frontend/default.conf`, and the `X-Robots-Tag` header from its `location /`.
|
||||
|
||||
## [v1.35.0]
|
||||
|
||||
### Purging deleted rooms
|
||||
|
||||
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
@@ -38,17 +38,6 @@ server {
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
location = / {
|
||||
try_files /home.html /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
location = /home.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -56,7 +45,6 @@ server {
|
||||
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;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
|
||||
@@ -65,8 +65,7 @@ RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
|
||||
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
|
||||
'brotli-libs>=1.2.1-r0' && \
|
||||
apk del curl && \
|
||||
rm -f /usr/share/nginx/html/50x.html
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -43,7 +43,6 @@ server {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires 0 always;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
|
||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
||||
|
||||
@@ -77,17 +76,6 @@ server {
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
location = / {
|
||||
try_files /home.html /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
location = /home.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -95,7 +83,6 @@ server {
|
||||
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;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
|
||||
@@ -37,7 +37,7 @@ $ make help
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or by email at [support-visio@numerique.gouv.fr](mailto:support-visio@numerique.gouv.fr).
|
||||
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or from the [support hotline](https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ These are the environmental options available on meet backend.
|
||||
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | La Suite support form (`widget_script_url`, `widget_api_url`, `widget_channel`) and help articles (`help_article_*`) | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<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>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="taskpane.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title data-app-name></title>
|
||||
<link rel="stylesheet" href="../styles/spinner.css" />
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
|
||||
@@ -184,7 +184,7 @@ def test_api_rooms_list_with_application_disabled(mock_rs_authenticate, settings
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
"""Listing rooms with invalid resource server token should return 401."""
|
||||
"""Listing rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
@@ -202,8 +202,9 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "introspected user is not active" in str(response.data).lower()
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_list_missing_scope():
|
||||
@@ -421,7 +422,7 @@ def test_api_rooms_retrieve_with_expired_token(settings):
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
||||
"""Retrieving rooms with invalid resource server token should return 401."""
|
||||
"""Retrieving rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
@@ -442,8 +443,9 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "introspected user is not active" in str(response.data).lower()
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_scope():
|
||||
@@ -645,7 +647,7 @@ def test_api_rooms_create_with_expired_token(settings):
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_create_with_invalid_rs_token(settings):
|
||||
"""Creating rooms with invalid resource server token should return 401."""
|
||||
"""Creating rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
@@ -663,8 +665,9 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "introspected user is not active" in str(response.data).lower()
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_inactive_user():
|
||||
@@ -2968,36 +2971,6 @@ def test_api_rooms_missing_token_is_audited_as_denial(audit_events):
|
||||
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_invalid_rs_token_is_audited_as_denial(audit_events, settings):
|
||||
"""A token refused by the identity provider is recorded as a denial."""
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={"iss": "https://oidc.example.com", "active": False},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers["WWW-Authenticate"] == "Bearer"
|
||||
responses.assert_call_count("https://oidc.example.com/introspect", 1)
|
||||
|
||||
[event] = find_events(audit_events, "room.list")
|
||||
|
||||
assert event["event"]["category"] == ["api", "authentication"]
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert event["http"]["response"] == {"status_code": 401}
|
||||
assert event["error"]["message"] == response.json()["detail"]
|
||||
|
||||
|
||||
def test_api_rooms_missing_scope_is_audited_as_denial(audit_events):
|
||||
"""A token without the required scope is a permission denial by the application."""
|
||||
user = UserFactory()
|
||||
|
||||
@@ -33,7 +33,7 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==9.0.0",
|
||||
"django-filter==26.1",
|
||||
"django-lasuite[all]==1.0.0",
|
||||
"django-lasuite[all]==0.0.29",
|
||||
"django-parler==2.4",
|
||||
"redis==5.2.1",
|
||||
"django-redis==7.0.0",
|
||||
|
||||
Generated
+10
-21
@@ -837,28 +837,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-lasuite"
|
||||
version = "1.0.0"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "joserfc" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/35335b826b039c43da68a0e624e154f60c3ac6c83f01f9b4ad6ce91faf03/django_lasuite-1.0.0.tar.gz", hash = "sha256:8921aaba5a78f1f9728cee2cc4596755478108f17146d4a2fccb393c043a05a3", size = 45815, upload-time = "2026-10-30T14:57:31.764Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/ab/72a79b7133fb2d17835bef40f45f7e6d2b4343469c37fd00386122e9068d/django_lasuite-0.0.29.tar.gz", hash = "sha256:831cb859e45e33963c9d89168c2de14f9844e805000b0f0d1512ffd0b9731a39", size = 35248, upload-time = "2026-08-25T13:03:43.597Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/6f/d1df3f2881cda711e569b8e685ad3a846ca9c71226340597cee1c09beae0/django_lasuite-1.0.0-py3-none-any.whl", hash = "sha256:23d589be9496b1f484772b49f3c4c4a81e70affd537e3c50abf15303c84d7cb0", size = 68529, upload-time = "2026-10-30T14:57:30.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cf/86648643221816f18ebd5a3e59b6d2a85ee85b38b19c7abe939f798a2845/django_lasuite-0.0.29-py3-none-any.whl", hash = "sha256:33bcff90ced2709190ef01241badb17ceed08a32725b9cdd3105650b36da303b", size = 54899, upload-time = "2026-08-25T13:03:42.212Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "celery" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "django-configurations" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "joserfc" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-icap" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1442,7 +1440,7 @@ requires-dist = [
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==9.0.0" },
|
||||
{ name = "django-filter", specifier = "==26.1" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==1.0.0" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.29" },
|
||||
{ name = "django-parler", specifier = "==2.4" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.5.4" },
|
||||
{ name = "django-redis", specifier = "==7.0.0" },
|
||||
@@ -2260,15 +2258,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-icap"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/0a/123c30f4041eabb94c587d35fb64aa9498435d028a8577340a4e5adc5224/python_icap-0.2.0.tar.gz", hash = "sha256:627869ac29119074542de6ae95370914854fdb35ee0a13c8ae4c55847cfcb80e", size = 684516, upload-time = "2026-07-08T22:21:58.842Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f2/b437a5e08b94dfc4cd8505cfc3e63922247e9ce3957707550ab08c514560/python_icap-0.2.0-py3-none-any.whl", hash = "sha256:b648d55b68abe387849a124d1b4a443801342a566afd2141f4515953ed4bd738", size = 70680, upload-time = "2026-07-08T22:21:57.307Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-magic"
|
||||
version = "0.4.27"
|
||||
|
||||
@@ -54,8 +54,7 @@ RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
|
||||
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
|
||||
'brotli-libs>=1.2.1-r0' && \
|
||||
apk del curl && \
|
||||
rm -f /usr/share/nginx/html/50x.html
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
|
||||
@@ -16,17 +16,6 @@ server {
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
location = / {
|
||||
try_files /home.html /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
location = /home.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -34,7 +23,6 @@ server {
|
||||
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;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+7
@@ -21,6 +21,7 @@
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.34.1",
|
||||
"i18next": "26.3.6",
|
||||
@@ -4562,6 +4563,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crisp-sdk-web": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.1.2.tgz",
|
||||
"integrity": "sha512-qxSpiT4EGHnVEO6J/t1UURY6fg1ojgquLU0IIr7RNjZxXZs1R2e6AhAiFqxiGZ13Vb4o5VoUpATFU2TzX/ppOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.34.1",
|
||||
"i18next": "26.3.6",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
User-agent: GPTBot
|
||||
User-agent: OAI-SearchBot
|
||||
User-agent: ChatGPT-User
|
||||
User-agent: ClaudeBot
|
||||
User-agent: Claude-SearchBot
|
||||
User-agent: Claude-User
|
||||
User-agent: PerplexityBot
|
||||
User-agent: Perplexity-User
|
||||
User-agent: Google-Extended
|
||||
User-agent: Applebot-Extended
|
||||
User-agent: CCBot
|
||||
User-agent: Meta-ExternalAgent
|
||||
User-agent: Amazonbot
|
||||
User-agent: Bytespider
|
||||
Disallow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -13,9 +13,7 @@ export interface ApiConfig {
|
||||
flags_api_host?: string
|
||||
}
|
||||
support?: {
|
||||
widget_script_url?: string
|
||||
widget_api_url?: string
|
||||
widget_channel?: string
|
||||
id: string
|
||||
help_article_transcript: string
|
||||
help_article_recording: string
|
||||
help_article_more_tools: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth/api/useSyncUserPreferencesWithBackend'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
@@ -7,9 +8,10 @@ export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
useSyncUserPreferencesWithBackend()
|
||||
|
||||
const { analytics = {}, custom_css_url = '' } = data ?? {}
|
||||
const { analytics = {}, support = {}, custom_css_url = '' } = data ?? {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
|
||||
useEffect(() => {
|
||||
if (custom_css_url) {
|
||||
|
||||
@@ -32,7 +32,6 @@ export type LogCode =
|
||||
| 'fullscreen_failure'
|
||||
| 'publish_sources_failure'
|
||||
| 'disconnect_failure'
|
||||
| 'support_widget_failure'
|
||||
| 'generic_failure'
|
||||
|
||||
export const reportError = (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiUrl } from '@/api/apiUrl'
|
||||
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
|
||||
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
|
||||
const logoutUrl = () => {
|
||||
@@ -7,5 +8,6 @@ const logoutUrl = () => {
|
||||
|
||||
export const logout = async () => {
|
||||
await terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
|
||||
+9
-4
@@ -2,18 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSupportWidget } from '@/features/support/hooks/useSupportWidget'
|
||||
import {
|
||||
useIsSupportEnabled,
|
||||
openSupportChat,
|
||||
} from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const SupportMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isEnabled, open } = useSupportWidget()
|
||||
const isSupportEnabled = useIsSupportEnabled()
|
||||
|
||||
if (!isEnabled) return
|
||||
if (!isSupportEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={open}
|
||||
onAction={openSupportChat}
|
||||
>
|
||||
<RiQuestionLine size={20} />
|
||||
{t('support')}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useSupportWidget } from '../hooks/useSupportWidget'
|
||||
|
||||
export const SupportButton = () => {
|
||||
const { t } = useTranslation('global', { keyPrefix: 'support' })
|
||||
const { isEnabled, isPending, open } = useSupportWidget()
|
||||
|
||||
if (!isEnabled) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
square
|
||||
variant="secondaryText"
|
||||
aria-label={t('buttonLabel')}
|
||||
tooltip={t('buttonLabel')}
|
||||
isPending={isPending}
|
||||
onPress={open}
|
||||
>
|
||||
{isPending ? <Spinner size={24} /> : <RiQuestionLine />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocation } from 'wouter'
|
||||
import { RiCheckboxCircleFill, RiCloseLine } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { announceToScreenReader } from '@/stores/screenReaderAnnouncer'
|
||||
import { supportStore } from '@/stores/support'
|
||||
import { subscribeToSupportWidget } from '../utils/supportWidget'
|
||||
import { ToastDuration } from '@/features/notifications/NotificationDuration'
|
||||
import { StyledToast } from '@/features/notifications/components/StyledToast'
|
||||
import { StyledToastContainer } from '@/features/notifications/components/StyledToastContainer'
|
||||
|
||||
const hide = () => {
|
||||
supportStore.showSentConfirmation = false
|
||||
}
|
||||
|
||||
export const SupportWidget = () => {
|
||||
const { t } = useTranslation('global', { keyPrefix: 'support.sent' })
|
||||
const { showSentConfirmation } = useSnapshot(supportStore)
|
||||
const [location] = useLocation()
|
||||
|
||||
useEffect(() => subscribeToSupportWidget(), [])
|
||||
|
||||
useEffect(hide, [location])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSentConfirmation) return
|
||||
announceToScreenReader(`${t('title')}. ${t('body')}`)
|
||||
const timeout = window.setTimeout(hide, ToastDuration.EXTRA_LONG)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [showSentConfirmation, t])
|
||||
|
||||
if (!showSentConfirmation) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
})}
|
||||
>
|
||||
<StyledToastContainer>
|
||||
<StyledToast>
|
||||
<RiCheckboxCircleFill
|
||||
size={24}
|
||||
aria-hidden="true"
|
||||
className={css({ color: 'success.300', flexShrink: 0 })}
|
||||
/>
|
||||
<div>
|
||||
<Text margin={false} bold>
|
||||
{t('title')}
|
||||
</Text>
|
||||
<Text margin={false}>{t('body')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
square
|
||||
size="sm"
|
||||
invisible
|
||||
aria-label={t('close')}
|
||||
onPress={hide}
|
||||
>
|
||||
<RiCloseLine color="white" />
|
||||
</Button>
|
||||
</StyledToast>
|
||||
</StyledToastContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
|
||||
|
||||
let crisp: CrispSdk | undefined
|
||||
let crispPromise: Promise<CrispSdk> | undefined
|
||||
|
||||
const loadCrisp = (): Promise<CrispSdk> => {
|
||||
crispPromise ??= import('crisp-sdk-web')
|
||||
.then((module) => {
|
||||
crisp = module.Crisp
|
||||
return module.Crisp
|
||||
})
|
||||
.catch((error) => {
|
||||
crispPromise = undefined
|
||||
throw error
|
||||
})
|
||||
|
||||
return crispPromise
|
||||
}
|
||||
|
||||
export const openSupportChat = () => {
|
||||
if (!crisp?.isCrispInjected()) return
|
||||
crisp.chat.open()
|
||||
}
|
||||
|
||||
export const initializeSupportSession = (user: ApiUser) => {
|
||||
if (!crisp?.isCrispInjected()) return
|
||||
|
||||
const { id, email } = user
|
||||
crisp.setTokenId(`meet-${id}`)
|
||||
if (email) crisp.user.setEmail(email)
|
||||
}
|
||||
|
||||
export const terminateSupportSession = () => {
|
||||
if (!crisp?.isCrispInjected()) return
|
||||
|
||||
crisp.setTokenId()
|
||||
crisp.session.reset()
|
||||
}
|
||||
|
||||
export type useSupportProps = {
|
||||
id?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
const IDLE_TIMEOUT_MS = 10_000
|
||||
|
||||
const scheduleWhenIdle = (callback: () => void): (() => void) => {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(callback, {
|
||||
timeout: IDLE_TIMEOUT_MS,
|
||||
})
|
||||
return () => window.cancelIdleCallback(handle)
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(callback, 1)
|
||||
return () => window.clearTimeout(handle)
|
||||
}
|
||||
|
||||
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
|
||||
const { user } = useUser()
|
||||
const [isInjected, setIsInjected] = useState(
|
||||
() => crisp?.isCrispInjected() ?? false
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || isDisabled) return
|
||||
|
||||
if (crisp?.isCrispInjected()) {
|
||||
setIsInjected(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const cancelIdle = scheduleWhenIdle(() => {
|
||||
void loadCrisp()
|
||||
.then((sdk) => {
|
||||
if (cancelled) return
|
||||
|
||||
if (!sdk.isCrispInjected()) {
|
||||
sdk.configure(id)
|
||||
sdk.setHideOnMobile(true)
|
||||
}
|
||||
|
||||
setIsInjected(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to initialize support chat', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelIdle()
|
||||
}
|
||||
}, [id, isDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !isInjected || isDisabled) return
|
||||
initializeSupportSession(user)
|
||||
}, [user, isInjected, isDisabled])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Some users block the chat widget, so check its availability safely.
|
||||
const isCrispAvailable = () => {
|
||||
try {
|
||||
return !!window?.$crisp?.is
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const useIsSupportEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
return !!data?.support?.id && isCrispAvailable()
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { announceToScreenReader } from '@/stores/screenReaderAnnouncer'
|
||||
import { supportStore } from '@/stores/support'
|
||||
import { openSupportWidget } from '../utils/supportWidget'
|
||||
|
||||
export const useSupportWidget = () => {
|
||||
const { t, i18n } = useTranslation('global', { keyPrefix: 'support' })
|
||||
const { data } = useConfig()
|
||||
const { user } = useUser()
|
||||
const { status } = useSnapshot(supportStore)
|
||||
|
||||
const {
|
||||
widget_script_url: scriptUrl,
|
||||
widget_api_url: api,
|
||||
widget_channel: channel,
|
||||
} = data?.support ?? {}
|
||||
const isEnabled = !!(scriptUrl && api && channel)
|
||||
|
||||
const open = () => {
|
||||
if (!isEnabled) return
|
||||
|
||||
openSupportWidget(scriptUrl, {
|
||||
api,
|
||||
channel,
|
||||
locale: i18n.language,
|
||||
email: user?.email,
|
||||
}).catch((error) => {
|
||||
reportError('support_widget_failure', error)
|
||||
announceToScreenReader(t('openError'), 'assertive')
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isPending: status === 'pending',
|
||||
open,
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { supportStore } from '@/stores/support'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_lasuite_widget?: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
type WidgetOptions = {
|
||||
api: string
|
||||
channel: string
|
||||
locale: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
const ROOT_ID = 'lasuite-widget-support-root'
|
||||
const OPENED_EVENT = 'lasuite-widget-support-opened'
|
||||
const SUCCESS_EVENT = 'lasuite-widget-support-success'
|
||||
const CLOSED_EVENT = 'lasuite-widget-support-closed'
|
||||
const TIMEOUT_MS = 10_000
|
||||
|
||||
const WIDGET_CSS = `@media (min-width: 48em) {
|
||||
[role='dialog'] {
|
||||
top: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
[role='status'] {
|
||||
display: none;
|
||||
}`
|
||||
|
||||
const styleWidget = () => {
|
||||
const shadowRoot = document.getElementById(ROOT_ID)?.shadowRoot
|
||||
if (!shadowRoot) return
|
||||
const style = document.createElement('style')
|
||||
style.textContent = WIDGET_CSS
|
||||
shadowRoot.appendChild(style)
|
||||
}
|
||||
|
||||
let returnFocusTo: HTMLElement | null = null
|
||||
|
||||
// The widget removes the form without moving focus, which drops it on <body>.
|
||||
const restoreFocus = () => {
|
||||
const activeEl = document.activeElement
|
||||
const isFocusLost = activeEl === document.body || activeEl?.id === ROOT_ID
|
||||
if (isFocusLost && returnFocusTo?.isConnected) {
|
||||
returnFocusTo.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
export const subscribeToSupportWidget = () => {
|
||||
const onOpened = () => {
|
||||
styleWidget()
|
||||
supportStore.status = 'open'
|
||||
}
|
||||
const onSuccess = () => {
|
||||
supportStore.showSentConfirmation = true
|
||||
restoreFocus()
|
||||
}
|
||||
const onClosed = () => {
|
||||
supportStore.status = 'closed'
|
||||
restoreFocus()
|
||||
returnFocusTo = null
|
||||
}
|
||||
|
||||
document.addEventListener(OPENED_EVENT, onOpened)
|
||||
document.addEventListener(SUCCESS_EVENT, onSuccess)
|
||||
document.addEventListener(CLOSED_EVENT, onClosed)
|
||||
return () => {
|
||||
document.removeEventListener(OPENED_EVENT, onOpened)
|
||||
document.removeEventListener(SUCCESS_EVENT, onSuccess)
|
||||
document.removeEventListener(CLOSED_EVENT, onClosed)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTrigger = () => {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
if (activeEl?.getAttribute('role') === 'menuitem') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
}
|
||||
return activeEl
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | undefined
|
||||
|
||||
const loadScript = (src: string): Promise<void> => {
|
||||
scriptPromise ??= new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
const timeout = window.setTimeout(
|
||||
() => fail(new Error(`Timed out loading ${src}`)),
|
||||
TIMEOUT_MS
|
||||
)
|
||||
const fail = (error: Error) => {
|
||||
window.clearTimeout(timeout)
|
||||
script.remove()
|
||||
scriptPromise = undefined
|
||||
reject(error)
|
||||
}
|
||||
|
||||
script.src = src
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
window.clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
script.onerror = () => fail(new Error(`Failed to load ${src}`))
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
|
||||
return scriptPromise
|
||||
}
|
||||
|
||||
const initWidget = (options: WidgetOptions): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const onOpened = () => {
|
||||
window.clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
document.removeEventListener(OPENED_EVENT, onOpened)
|
||||
reject(new Error('Support widget did not open'))
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
document.addEventListener(OPENED_EVENT, onOpened, { once: true })
|
||||
window._lasuite_widget?.push(['support', 'init', options])
|
||||
})
|
||||
|
||||
export const openSupportWidget = async (
|
||||
scriptUrl: string,
|
||||
options: WidgetOptions
|
||||
) => {
|
||||
if (supportStore.status !== 'closed') return
|
||||
|
||||
supportStore.status = 'pending'
|
||||
returnFocusTo = resolveTrigger()
|
||||
try {
|
||||
await loadScript(scriptUrl)
|
||||
await initWidget(options)
|
||||
} catch (error) {
|
||||
supportStore.status = 'closed'
|
||||
returnFocusTo = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { HStack, Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { SettingsButton } from '@/features/settings'
|
||||
import { SupportButton } from '@/features/support/components/SupportButton'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||
import { FeedbackBanner } from '@/components/FeedbackBanner'
|
||||
@@ -154,7 +153,6 @@ export const Header = () => {
|
||||
</header>
|
||||
<nav>
|
||||
<Stack gap={1} direction="row" align="center">
|
||||
<SupportButton />
|
||||
{isLoggedIn === false &&
|
||||
!isHome &&
|
||||
!isLegalTerms &&
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useSnapshot } from 'valtio'
|
||||
import { Footer } from '@/layout/Footer'
|
||||
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
|
||||
import { SupportWidget } from '@/features/support/components/SupportWidget'
|
||||
|
||||
export type Layout = 'fullpage' | 'centered'
|
||||
|
||||
@@ -47,7 +46,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
})}
|
||||
>
|
||||
<ScreenReaderAnnouncer />
|
||||
<SupportWidget />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
},
|
||||
"selected": "ausgewählt",
|
||||
"submit": "OK",
|
||||
"support": {
|
||||
"buttonLabel": "Support kontaktieren",
|
||||
"openError": "Das Kontaktformular konnte nicht geöffnet werden. Bitte versuchen Sie es später erneut.",
|
||||
"sent": {
|
||||
"title": "Nachricht gesendet",
|
||||
"body": "Das Support-Team hat Ihre Nachricht erhalten.",
|
||||
"close": "Schließen"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
},
|
||||
"selected": "selected",
|
||||
"submit": "OK",
|
||||
"support": {
|
||||
"buttonLabel": "Contact support",
|
||||
"openError": "The contact form could not be opened. Please try again later.",
|
||||
"sent": {
|
||||
"title": "Message sent",
|
||||
"body": "The support team has received your message.",
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
},
|
||||
"selected": "seleccionado",
|
||||
"submit": "Aceptar",
|
||||
"support": {
|
||||
"buttonLabel": "Contactar con el soporte",
|
||||
"openError": "No se ha podido abrir el formulario de contacto. Inténtelo de nuevo más tarde.",
|
||||
"sent": {
|
||||
"title": "Mensaje enviado",
|
||||
"body": "El equipo de soporte ha recibido su mensaje.",
|
||||
"close": "Cerrar"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
},
|
||||
"selected": "sélectionné",
|
||||
"submit": "OK",
|
||||
"support": {
|
||||
"buttonLabel": "Contacter l'aide",
|
||||
"openError": "Impossible d'ouvrir le formulaire de contact. Réessayez plus tard.",
|
||||
"sent": {
|
||||
"title": "Message envoyé",
|
||||
"body": "L'équipe support a bien reçu votre message.",
|
||||
"close": "Fermer"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
|
||||
@@ -26,15 +26,6 @@
|
||||
},
|
||||
"selected": "geselecteerd",
|
||||
"submit": "OK",
|
||||
"support": {
|
||||
"buttonLabel": "Contact ondersteuning",
|
||||
"openError": "Het contactformulier kon niet worden geopend. Probeer het later opnieuw.",
|
||||
"sent": {
|
||||
"title": "Bericht verzonden",
|
||||
"body": "Het supportteam heeft je bericht ontvangen.",
|
||||
"close": "Sluiten"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export type SupportWidgetStatus = 'closed' | 'pending' | 'open'
|
||||
|
||||
type State = {
|
||||
status: SupportWidgetStatus
|
||||
showSentConfirmation: boolean
|
||||
}
|
||||
|
||||
export const supportStore = proxy<State>({
|
||||
status: 'closed',
|
||||
showSentConfirmation: false,
|
||||
})
|
||||
@@ -72,6 +72,14 @@ main#main-content :is(h1, h2, h3)[tabindex='-1']:focus {
|
||||
}
|
||||
}
|
||||
|
||||
body:has(.lk-video-conference) #crisp-chatbox-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html:has(.lk-video-conference) {
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { defineConfig, loadEnv, type Plugin } from 'vite'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import svgr from 'vite-plugin-svgr'
|
||||
@@ -26,23 +25,6 @@ if (mediapipeVersion !== livekitMediapipeVersion) {
|
||||
)
|
||||
}
|
||||
|
||||
const NOINDEX_TAG = '<meta name="robots" content="noindex, nofollow" />'
|
||||
|
||||
const homePage = (): Plugin => ({
|
||||
name: 'home-page',
|
||||
apply: 'build',
|
||||
writeBundle({ dir = 'dist' }) {
|
||||
const html = readFileSync(join(dir, 'index.html'), 'utf-8')
|
||||
if (!html.includes(NOINDEX_TAG)) {
|
||||
throw new Error(
|
||||
`${dir}/index.html does not contain ${NOINDEX_TAG}, so home.html ` +
|
||||
`cannot be generated from it. The two must stay in sync.`
|
||||
)
|
||||
}
|
||||
writeFileSync(join(dir, 'home.html'), html.replace(NOINDEX_TAG, ''))
|
||||
},
|
||||
})
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
@@ -84,7 +66,6 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
homePage(),
|
||||
env.VITE_ANALYZE === 'true' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
|
||||
@@ -163,7 +163,7 @@ backend:
|
||||
LIVEKIT_DEFAULT_VIDEO_CODEC: vp9
|
||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
FRONTEND_SUPPORT: "{'help_article_transcript': 'https://docs.numerique.gouv.fr/docs/ba213749-2379-421c-b2ea-e4bd0840ea9b', 'help_article_recording': 'https://docs.numerique.gouv.fr/docs/a4e12e41-2974-472c-84a9-c00d12c10769', 'help_article_more_tools': 'https://docs.numerique.gouv.fr/docs/75fa98c7-5af1-433e-a611-e38143db26d0'}"
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/7c5bd65d-3c21-486f-bce1-26e0a921d642/"
|
||||
FRONTEND_TECHNICAL_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
|
||||
|
||||
@@ -51,7 +51,6 @@ data:
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires 0 always;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
|
||||
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
||||
|
||||
@@ -85,17 +84,6 @@ data:
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
location = / {
|
||||
try_files /home.html /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
location = /home.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -103,7 +91,6 @@ data:
|
||||
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;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary.core.analytics import get_analytics
|
||||
from summary.core.celery_worker import (
|
||||
@@ -13,7 +14,9 @@ from summary.core.celery_worker import (
|
||||
summarize_v2_task,
|
||||
)
|
||||
from summary.core.config import AuthorizedTenant, get_settings
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.security import verify_tenant_api_key_v2
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookFailurePayload,
|
||||
@@ -52,7 +55,7 @@ async def create_transcribe_task_v2(
|
||||
task = process_audio_transcribe_v2_task.apply_async(
|
||||
args=[
|
||||
{
|
||||
**request.model_dump(),
|
||||
**dump_for_transport(request),
|
||||
"tenant_id": request_tenant.id,
|
||||
"received_at": datetime.now(timezone.utc),
|
||||
}
|
||||
@@ -125,7 +128,11 @@ async def get_transcribe_job_status(
|
||||
|
||||
if task.status == "SUCCESS":
|
||||
result = task.result
|
||||
return TranscribeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||
try:
|
||||
payload = TranscribeWebhookSuccessPayload.model_validate(result)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
return dump_for_transport(payload)
|
||||
|
||||
if task.status == "FAILURE":
|
||||
return TranscribeWebhookFailurePayload(
|
||||
@@ -155,7 +162,11 @@ async def get_summarize_job_status(
|
||||
|
||||
if task.status == "SUCCESS":
|
||||
result = task.result
|
||||
return SummarizeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||
try:
|
||||
payload = SummarizeWebhookSuccessPayload.model_validate(result)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
return dump_for_transport(payload)
|
||||
|
||||
if task.status == "FAILURE":
|
||||
return SummarizeWebhookFailurePayload(
|
||||
|
||||
@@ -5,7 +5,6 @@ import socket
|
||||
import time
|
||||
from collections import Counter
|
||||
from functools import cached_property, lru_cache
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import redis
|
||||
from celery.utils.log import get_task_logger
|
||||
@@ -13,6 +12,7 @@ from posthog import Posthog
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
|
||||
from summary.core.privacy import redact_url
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
@@ -54,8 +54,8 @@ class Analytics:
|
||||
self._client.capture(
|
||||
event_name, distinct_id=distinct_id, properties=properties
|
||||
)
|
||||
except Exception as e:
|
||||
raise AnalyticsException("Failed to capture analytics event") from e
|
||||
except Exception:
|
||||
raise AnalyticsException("Failed to capture analytics event") from None
|
||||
|
||||
def is_feature_enabled(self, feature_name: str, distinct_id: str = None) -> bool:
|
||||
"""Check if a feature flag is enabled for a user."""
|
||||
@@ -64,8 +64,8 @@ class Analytics:
|
||||
|
||||
try:
|
||||
return self._client.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e:
|
||||
logger.error("Error checking feature flag %s: %s", feature_name, e)
|
||||
except Exception:
|
||||
logger.error("Error checking feature flag %s", feature_name)
|
||||
return False
|
||||
|
||||
|
||||
@@ -133,8 +133,7 @@ class MetadataManager:
|
||||
}
|
||||
|
||||
if isinstance(task_payload, TranscribeTaskJob):
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
clean_url = redact_url(task_payload.cloud_storage_url.get_secret_value())
|
||||
initial_metadata["source_url"] = clean_url
|
||||
initial_metadata["asr_model"] = settings.whisperx_asr_model
|
||||
elif isinstance(task_payload, SummarizeTaskJob):
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from celery import Celery, signals
|
||||
from celery import Celery, Task, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from openai.types.audio import Transcription
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from requests import exceptions
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.docs_service import create_document_in_lasuite_docs
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
from summary.core.file_service import (
|
||||
CorruptedAudioFile,
|
||||
FileService,
|
||||
@@ -31,6 +34,7 @@ from summary.core.models import (
|
||||
SummarizeTaskJob,
|
||||
TranscribeTaskJob,
|
||||
)
|
||||
from summary.core.privacy import dump_for_transport, redact_url
|
||||
from summary.core.prompt import (
|
||||
FORMAT_NEXT_STEPS,
|
||||
FORMAT_PLAN,
|
||||
@@ -63,8 +67,24 @@ metadata_manager = MetadataManager()
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _reveal(secret: SecretStr | None) -> str | None:
|
||||
"""Return the underlying value of an optional secret."""
|
||||
return secret.get_secret_value() if secret is not None else None
|
||||
|
||||
|
||||
class PrivateTask(Task):
|
||||
"""Hide task arguments in Celery logs and monitoring events."""
|
||||
|
||||
def apply_async(self, args=None, kwargs=None, **options):
|
||||
"""Publish with redacted display representations."""
|
||||
options.update(argsrepr="[Filtered]", kwargsrepr="[Filtered]")
|
||||
return super().apply_async(args=args, kwargs=kwargs, **options)
|
||||
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
task_cls=PrivateTask,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
@@ -76,10 +96,28 @@ celery = Celery(
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
|
||||
class CeleryLogPrivacyFilter(logging.Filter):
|
||||
"""Prevent Celery trace logs from serializing task results or exceptions."""
|
||||
|
||||
def filter(self, record):
|
||||
"""Keep task identity and state without upstream values."""
|
||||
if record.levelno < logging.INFO:
|
||||
return True
|
||||
data = getattr(record, "data", None)
|
||||
if isinstance(data, dict):
|
||||
record.msg = "Task %s[%s] finished or retried (details omitted)"
|
||||
record.args = (data.get("name", "unknown"), data.get("id", "unknown"))
|
||||
record.data = {}
|
||||
record.exc_info = None
|
||||
record.exc_text = None
|
||||
return True
|
||||
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_celery_sentry(**_kwargs):
|
||||
"""Initialize Sentry in the Celery worker."""
|
||||
init_sentry()
|
||||
logging.getLogger("celery.app.trace").addFilter(CeleryLogPrivacyFilter())
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
@@ -162,9 +200,9 @@ def transcribe_audio(
|
||||
try:
|
||||
res.raise_for_status()
|
||||
except requests.exceptions.HTTPError:
|
||||
logger.exception("WhisperX transcription failed")
|
||||
logger.error("WhisperX transcription failed")
|
||||
# We reraise the error so that it can be retried by celery
|
||||
raise
|
||||
raise requests.exceptions.HTTPError("WhisperX request failed") from None
|
||||
|
||||
transcription_json: dict[str, Any] = res.json()
|
||||
# We remove the "usage" key from the transcription_json dictionary
|
||||
@@ -175,13 +213,16 @@ def transcribe_audio(
|
||||
# We force the use of the Transcription model here
|
||||
# to avoid changing too much code for now.
|
||||
# Note that it should be WhisperXResponse instead.
|
||||
transcription = Transcription.model_validate(
|
||||
# We add a dummy "text" to make the model validate,
|
||||
# Some API responses lack the "text" key.
|
||||
{"text": "", **transcription_json},
|
||||
extra="allow",
|
||||
strict=False,
|
||||
)
|
||||
try:
|
||||
transcription = Transcription.model_validate(
|
||||
# We add a dummy "text" to make the model validate,
|
||||
# Some API responses lack the "text" key.
|
||||
{"text": "", **transcription_json},
|
||||
extra="allow",
|
||||
strict=False,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
|
||||
# Logging
|
||||
transcription_duration = round(time.time() - transcription_start_time, 2)
|
||||
@@ -199,9 +240,9 @@ def transcribe_audio(
|
||||
if raises:
|
||||
raise e
|
||||
redacted_cloud_storage_url = (
|
||||
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||
redact_url(cloud_storage_url) if cloud_storage_url else None
|
||||
)
|
||||
logger.exception(
|
||||
logger.error(
|
||||
("Unexpected error while preparing file %s "),
|
||||
redacted_cloud_storage_url,
|
||||
)
|
||||
@@ -230,7 +271,7 @@ def resolve_speaker_identities_and_apply_to(
|
||||
logger.debug("Running resolve_speaker_identities")
|
||||
try:
|
||||
metadata = file_service.read_cloud_storage_json(
|
||||
recording_metadata.cloud_storage_url
|
||||
recording_metadata.cloud_storage_url.get_secret_value()
|
||||
)
|
||||
speaker_mapping = resolve_speaker_identities(
|
||||
metadata,
|
||||
@@ -241,21 +282,18 @@ def resolve_speaker_identities_and_apply_to(
|
||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||
return WhisperXResponse.model_validate(new_transcription)
|
||||
|
||||
except FileServiceException as exc:
|
||||
except FileServiceException:
|
||||
logger.error(
|
||||
"Error reading metadata for task %s; skipping speaker assignment."
|
||||
" Error: %s",
|
||||
"Error reading metadata for task %s; skipping speaker assignment.",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
except Exception:
|
||||
logger.error(
|
||||
"resolve_speaker_identities failed for task %s; skipping"
|
||||
" speaker assignment. Error: %s",
|
||||
" speaker assignment.",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
@@ -302,6 +340,17 @@ def format_actions(llm_output: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_llm_json(value: str) -> dict:
|
||||
"""Parse an LLM response without exposing it in decoding errors."""
|
||||
try:
|
||||
result = json.loads(value)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
raise LLMException("Invalid LLM response") from None
|
||||
|
||||
|
||||
def summarize_transcription_internals(
|
||||
*, distinct_id: str, transcript: str, session_id: str
|
||||
) -> str:
|
||||
@@ -342,13 +391,13 @@ def summarize_transcription_internals(
|
||||
)
|
||||
logger.info("Plan generated")
|
||||
|
||||
res = json.loads(parts)
|
||||
res = parse_llm_json(parts)
|
||||
parts = res.get("titles", [])
|
||||
logger.info("Parts to summarize: %s", parts)
|
||||
logger.info("Parts to summarize: %s", len(parts))
|
||||
parts_summarized = []
|
||||
for part in parts:
|
||||
for i, part in enumerate(parts):
|
||||
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
|
||||
logger.info("Summarizing part: %s", part)
|
||||
logger.info("Summarizing part %s", i + 1)
|
||||
parts_summarized.append(
|
||||
llm_service.call(PROMPT_SYSTEM_PART, prompt_user_part, name="part")
|
||||
)
|
||||
@@ -364,7 +413,7 @@ def summarize_transcription_internals(
|
||||
response_format=FORMAT_NEXT_STEPS,
|
||||
)
|
||||
|
||||
next_steps = format_actions(json.loads(next_steps))
|
||||
next_steps = format_actions(parse_llm_json(next_steps))
|
||||
|
||||
logger.info("Next steps generated")
|
||||
|
||||
@@ -437,9 +486,11 @@ def call_webhook_v2_task(
|
||||
tenant_id: str,
|
||||
):
|
||||
"""Calls a webhook asynchrously (retry handled by celery)."""
|
||||
call_webhook_v2(
|
||||
payload=webhook_payload_adapter.validate_python(payload), tenant_id=tenant_id
|
||||
)
|
||||
try:
|
||||
validated_payload = webhook_payload_adapter.validate_python(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
call_webhook_v2(payload=validated_payload, tenant_id=tenant_id)
|
||||
|
||||
|
||||
@celery.task(
|
||||
@@ -465,7 +516,10 @@ def process_audio_transcribe_v2_task(
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||
"""
|
||||
payload = TranscribeTaskJob.model_validate(payload)
|
||||
try:
|
||||
payload = TranscribeTaskJob.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
logger.info(
|
||||
"Transcribing for object received | Owner: %s",
|
||||
payload.user_sub,
|
||||
@@ -477,7 +531,7 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_res = WhisperXResponse(
|
||||
**transcribe_audio( # type: ignore
|
||||
task_id=job_id,
|
||||
cloud_storage_url=payload.cloud_storage_url,
|
||||
cloud_storage_url=payload.cloud_storage_url.get_secret_value(),
|
||||
language=payload.language,
|
||||
raises=True,
|
||||
).model_dump()
|
||||
@@ -514,8 +568,8 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_res.model_dump(),
|
||||
payload.context_language,
|
||||
payload.language,
|
||||
payload.push_to_docs_config.download_link,
|
||||
payload.push_to_docs_config.form_link,
|
||||
_reveal(payload.push_to_docs_config.download_link),
|
||||
_reveal(payload.push_to_docs_config.form_link),
|
||||
)
|
||||
|
||||
create_document_in_lasuite_docs(
|
||||
@@ -556,18 +610,22 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_data_url=file_service.get_transcript_signed_url(job_id),
|
||||
)
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
args=[dump_for_transport(success_payload), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_transcript_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
return dump_for_transport(success_payload)
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||
try:
|
||||
payload = TranscribeTaskJob.model_validate(args[0])
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
metadata_manager.create(task_id, payload)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||
@@ -627,7 +685,10 @@ def summarize_v2_task(
|
||||
1. Run summary internals
|
||||
2. Sends the final summary via webhook.
|
||||
"""
|
||||
payload = SummarizeTaskJob.model_validate(payload)
|
||||
try:
|
||||
payload = SummarizeTaskJob.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
summary = summarize_transcription_internals(
|
||||
distinct_id=payload.user_sub,
|
||||
transcript=payload.content,
|
||||
@@ -652,18 +713,22 @@ def summarize_v2_task(
|
||||
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||
)
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
args=[dump_for_transport(success_payload), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_summary_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
return dump_for_transport(success_payload)
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=summarize_v2_task)
|
||||
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
|
||||
try:
|
||||
payload = SummarizeTaskJob.model_validate(args[0])
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
metadata_manager.create(task_id, payload)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=summarize_v2_task)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from requests import Session
|
||||
from requests import RequestException, Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
@@ -43,6 +43,8 @@ def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||
response = session.post(url, json=data, timeout=(20, 3 * 60))
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except RequestException:
|
||||
raise RequestException("Docs delivery failed") from None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Validation errors suitable for logging and transport."""
|
||||
|
||||
from typing import get_args
|
||||
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic_core import ErrorType, PydanticCustomError
|
||||
|
||||
_BUILTIN_ERROR_TYPES = frozenset(get_args(ErrorType))
|
||||
|
||||
|
||||
def clean_validation_errors(errors: list[dict]) -> list[dict]:
|
||||
"""Mask SecretStr values, including those nested in validation input."""
|
||||
|
||||
def clean(value):
|
||||
if isinstance(value, SecretStr):
|
||||
return "[redacted]"
|
||||
if isinstance(value, Exception):
|
||||
# Do not retain validator exception objects, whose args may hold secrets.
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: clean(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [clean(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(clean(item) for item in value)
|
||||
return value
|
||||
|
||||
return clean(errors)
|
||||
|
||||
|
||||
class CleanedValidationError(ValidationError):
|
||||
"""A new Pydantic exception with secret inputs replaced by a marker."""
|
||||
|
||||
def __init__(self, error, *args, secret_input=False, **kwargs):
|
||||
"""State is initialized by Pydantic during __new__."""
|
||||
|
||||
def __new__(cls, error, *args, secret_input=False, **kwargs):
|
||||
"""Accept an original error, or Pydantic's native construction arguments."""
|
||||
if not isinstance(error, ValidationError):
|
||||
# Pydantic uses this path when constructing or unpickling the error.
|
||||
return super().__new__(cls, error, *args, **kwargs)
|
||||
|
||||
details = clean_validation_errors(error.errors(include_url=False))
|
||||
for detail in details:
|
||||
if secret_input:
|
||||
# An adapter may have received the unwrapped value of a SecretStr.
|
||||
detail["input"] = "[redacted]"
|
||||
return cls._from_details(error.title, details)
|
||||
|
||||
@classmethod
|
||||
def _from_details(cls, title, details):
|
||||
"""Reconstruct builtin and custom Pydantic error codes."""
|
||||
for detail in details:
|
||||
if detail["type"] not in _BUILTIN_ERROR_TYPES:
|
||||
detail["type"] = PydanticCustomError(
|
||||
detail["type"], detail["msg"], detail.get("ctx")
|
||||
)
|
||||
return cls.from_exception_data(title, details, hide_input=True)
|
||||
|
||||
def __reduce__(self):
|
||||
"""Preserve custom error codes when Celery pickles the exception."""
|
||||
return self._from_details, (self.title, self.errors(include_url=False))
|
||||
@@ -17,6 +17,7 @@ import requests
|
||||
from botocore.config import Config
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import redact_url
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
settings = get_settings()
|
||||
@@ -314,7 +315,7 @@ class FileService:
|
||||
"""Download file from a cloud storage URL to local temporary file."""
|
||||
logger.info(
|
||||
"Download recording from URL | cloud_storage_url: %s",
|
||||
cloud_storage_url,
|
||||
redact_url(cloud_storage_url),
|
||||
)
|
||||
|
||||
if not cloud_storage_url:
|
||||
@@ -352,10 +353,10 @@ class FileService:
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
return local_path
|
||||
|
||||
except requests.RequestException as e:
|
||||
except requests.RequestException:
|
||||
raise FileServiceException(
|
||||
"Unexpected error while downloading object from cloud_storage_url."
|
||||
) from e
|
||||
) from None
|
||||
|
||||
def _validate_duration(self, duration_seconds: float) -> None:
|
||||
"""Validate audio file duration against configured maximum."""
|
||||
@@ -376,7 +377,7 @@ class FileService:
|
||||
|
||||
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
||||
"""Read and parse a JSON file from a url."""
|
||||
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||
logger.info("Reading JSON: %s", redact_url(cloud_storage_url))
|
||||
res = requests.get(cloud_storage_url, timeout=(10, 100))
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
@@ -140,6 +140,6 @@ class LLMService:
|
||||
response = self._client.chat.completions.create(**params)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
except Exception:
|
||||
logger.error("LLM call failed")
|
||||
raise LLMException("LLM call failed") from None
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field, field_validator
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
EmailStr,
|
||||
Field,
|
||||
field_validator,
|
||||
)
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.types import Url
|
||||
from summary.core.types import SecretUrl
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -13,6 +20,8 @@ settings = get_settings()
|
||||
class SharedV2TaskCreation(BaseModel):
|
||||
"""Model that holds basic information for task creation."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||
user_email: EmailStr | None = Field(
|
||||
default=None,
|
||||
@@ -24,7 +33,9 @@ class SharedV2TaskCreation(BaseModel):
|
||||
class RecordingMetadata(BaseModel):
|
||||
"""Model for recording metadata."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
cloud_storage_url: SecretUrl = Field(
|
||||
title="Cloud Storage URL",
|
||||
description="The URL of the metadata file for speaker assignment.",
|
||||
)
|
||||
@@ -35,6 +46,8 @@ class RecordingMetadata(BaseModel):
|
||||
class PushToDocsBaseConfig(BaseModel):
|
||||
"""Model containing information for pushing transcript and summaries to docs."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
user_email: EmailStr = Field(
|
||||
title="User Email", description="The user's email, future owner of the docs."
|
||||
)
|
||||
@@ -44,12 +57,12 @@ class PushToDocsBaseConfig(BaseModel):
|
||||
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
|
||||
"""Model for push to docs information for transcripts."""
|
||||
|
||||
download_link: Url | None = Field(
|
||||
download_link: SecretUrl | None = Field(
|
||||
default=None,
|
||||
title="Download Link",
|
||||
description="The link to download the recording.",
|
||||
)
|
||||
form_link: Url | None = Field(
|
||||
form_link: SecretUrl | None = Field(
|
||||
default=None,
|
||||
title="Form Link",
|
||||
description="The link to fill out a form for the recording.",
|
||||
@@ -69,7 +82,7 @@ class PushToDocsSummaryConfig(PushToDocsBaseConfig):
|
||||
class TranscribeTaskApiRequest(SharedV2TaskCreation):
|
||||
"""Model for creating a transcribe task (used for API request)."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
cloud_storage_url: SecretUrl = Field(
|
||||
title="Cloud storage URL",
|
||||
description="The URL of the audio file to transcribe.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Privacy helpers for observability."""
|
||||
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
|
||||
def dump_for_transport(model: BaseModel) -> dict:
|
||||
"""Reveal secrets explicitly for task messages, HTTP bodies and API responses."""
|
||||
|
||||
def reveal(value):
|
||||
if isinstance(value, SecretStr):
|
||||
return value.get_secret_value()
|
||||
if isinstance(value, dict):
|
||||
return {key: reveal(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [reveal(item) for item in value]
|
||||
return value
|
||||
|
||||
return reveal(model.model_dump())
|
||||
|
||||
|
||||
def redact_url(url):
|
||||
"""Keep the source identifiable without logging query or user credentials."""
|
||||
if not isinstance(url, str):
|
||||
return "[redacted]"
|
||||
# Remove query and fragment before parsing, including for malformed URLs.
|
||||
base, has_query, _ = url.partition("?")
|
||||
base = base.partition("#")[0]
|
||||
try:
|
||||
parts = urlsplit(base)
|
||||
except ValueError:
|
||||
return "[redacted]"
|
||||
authority = parts.netloc.rsplit("@", 1)[-1]
|
||||
redacted = urlunsplit(parts._replace(netloc=authority))
|
||||
return redacted + ("?[redacted]" if has_query else "")
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Sentry configuration."""
|
||||
|
||||
import re
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import redact_url
|
||||
|
||||
# Exact names (case-insensitive) of variables and dict keys to redact.
|
||||
SENSITIVE_DATA_DENYLIST = [
|
||||
@@ -90,5 +93,45 @@ def init_sentry() -> None:
|
||||
# Task creation requests carry the content to summarize.
|
||||
max_request_body_size="never",
|
||||
include_local_variables=True,
|
||||
before_send=before_send,
|
||||
before_send_transaction=before_send,
|
||||
before_breadcrumb=before_breadcrumb,
|
||||
event_scrubber=build_event_scrubber(),
|
||||
)
|
||||
|
||||
|
||||
def _scrub_strings(value):
|
||||
"""Remove URL credentials, queries and fragments throughout an event."""
|
||||
if isinstance(value, str):
|
||||
return re.sub(
|
||||
r"https?://[^\s\"'<>]+",
|
||||
lambda m: redact_url(m.group()),
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return {key: _scrub_strings(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_scrub_strings(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def before_send(event, hint):
|
||||
"""Keep exception types and stacks, but discard untrusted message text."""
|
||||
del hint
|
||||
event.pop("message", None)
|
||||
event.pop("logentry", None)
|
||||
request = event.get("request", {})
|
||||
request.pop("data", None)
|
||||
request.pop("query_string", None)
|
||||
for exception in event.get("exception", {}).get("values", []):
|
||||
exception["value"] = "[Filtered]"
|
||||
return _scrub_strings(event)
|
||||
|
||||
|
||||
def before_breadcrumb(crumb, hint):
|
||||
"""Drop arbitrary breadcrumb text and scrub URLs in structured data."""
|
||||
del hint
|
||||
crumb.pop("message", None)
|
||||
crumb.pop("data", None)
|
||||
return _scrub_strings(crumb)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
||||
|
||||
from summary.core.types import SecretUrl
|
||||
|
||||
|
||||
class WordSegment(BaseModel):
|
||||
@@ -65,6 +67,8 @@ class WhisperXResponse(BaseModel):
|
||||
class BaseWebhook(BaseModel):
|
||||
"""Base webhook payload."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
job_id: str = Field(
|
||||
title="Job ID",
|
||||
description="The ID of the job document in the receiver system.",
|
||||
@@ -76,7 +80,7 @@ class TranscribeWebhookSuccessPayload(BaseWebhook):
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
transcription_data_url: str = Field(
|
||||
transcription_data_url: SecretUrl = Field(
|
||||
title="Transcript", description="URL to the raw transcription data."
|
||||
)
|
||||
|
||||
@@ -121,7 +125,7 @@ class SummarizeWebhookSuccessPayload(BaseWebhook):
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
summary_data_url: str = Field(
|
||||
summary_data_url: SecretUrl = Field(
|
||||
title="Summary", description="URL to the raw summary data."
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BeforeValidator,
|
||||
HttpUrl,
|
||||
SecretStr,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
http_url_adapter = TypeAdapter(HttpUrl)
|
||||
Url = Annotated[
|
||||
str, BeforeValidator(lambda value: str(http_url_adapter.validate_python(value)))
|
||||
]
|
||||
|
||||
|
||||
def _validate_secret_url(value: SecretStr) -> SecretStr:
|
||||
"""Validate and normalize a URL without echoing it in validator messages."""
|
||||
try:
|
||||
return SecretStr(
|
||||
str(http_url_adapter.validate_python(value.get_secret_value()))
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
|
||||
|
||||
SecretUrl = Annotated[SecretStr, AfterValidator(_validate_secret_url)]
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import requests
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.shared_models import (
|
||||
WebhookPayloads,
|
||||
)
|
||||
@@ -29,16 +30,19 @@ def call_webhook_v2(
|
||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||
logger.debug("Request payload: %s", payload.model_dump_json(indent=2))
|
||||
|
||||
response = requests.post(
|
||||
tenant.webhook_url,
|
||||
json=payload.model_dump(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||
"User-Agent": settings.app_external_user_agent,
|
||||
},
|
||||
timeout=(10, 20),
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.post(
|
||||
tenant.webhook_url,
|
||||
json=dump_for_transport(payload),
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||
"User-Agent": settings.app_external_user_agent,
|
||||
},
|
||||
timeout=(10, 20),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException:
|
||||
raise requests.RequestException("Webhook delivery failed") from None
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Application."""
|
||||
|
||||
from dockerflow.fastapi import router as dockerflow_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.exceptions import clean_validation_errors
|
||||
from summary.core.sentry import init_sentry
|
||||
|
||||
settings = get_settings()
|
||||
@@ -19,3 +23,13 @@ app = FastAPI(
|
||||
|
||||
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||
app.include_router(dockerflow_router)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
||||
"""Keep validation details while masking SecretStr input values."""
|
||||
del request
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=jsonable_encoder({"detail": clean_validation_errors(exc.errors())}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
|
||||
class TestTasksV2:
|
||||
"""Tests for the /v2/async-jobs-jobs endpoints."""
|
||||
@@ -174,3 +178,163 @@ class TestTasksV2:
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Forbidden"}
|
||||
|
||||
|
||||
def test_validation_preserves_ordinary_input(client):
|
||||
"""Validation errors keep input values that are not SecretStr."""
|
||||
canary = "private-meeting-content"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/summarize",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={"user_sub": "user", "content": {"unexpected": canary}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary in response.text
|
||||
assert response.json()["detail"] == [
|
||||
{
|
||||
"type": "string_type",
|
||||
"loc": ["body", "content"],
|
||||
"msg": "Input should be a valid string",
|
||||
"input": {"unexpected": canary},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_validation_preserves_context(client):
|
||||
"""Validator errors retain location, message and serializable context."""
|
||||
canary = "private-invalid-url-signature"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": f"invalid-url?signature={canary}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary not in response.text
|
||||
assert response.json()["detail"] == [
|
||||
{
|
||||
"type": "url_parsing",
|
||||
"loc": ["body", "cloud_storage_url"],
|
||||
"msg": "Input should be a valid URL, relative URL without a base",
|
||||
"input": "[redacted]",
|
||||
"ctx": {"error": "relative URL without a base"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@patch(
|
||||
"summary.api.route.tasks_v2.process_audio_transcribe_v2_task.apply_async",
|
||||
return_value=MagicMock(id="transcribe-job"),
|
||||
)
|
||||
def test_signed_urls_are_unwrapped_for_task_submission(mock_apply_async, client):
|
||||
"""API input secrets reach Celery intact, including nested metadata URLs."""
|
||||
signed_url = "https://storage.example.com/file?signature=deterministic-signature"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": signed_url,
|
||||
"metadata": {
|
||||
"cloud_storage_url": signed_url,
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"ended_at": "2026-01-01T11:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = mock_apply_async.call_args.kwargs["args"][0]
|
||||
assert payload["cloud_storage_url"] == signed_url
|
||||
assert payload["metadata"]["cloud_storage_url"] == signed_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
("transcribe", "transcript", "transcription_data_url"),
|
||||
("summarize", "summary", "summary_data_url"),
|
||||
],
|
||||
)
|
||||
@patch("summary.api.route.tasks_v2.celery")
|
||||
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||
def test_success_status_returns_usable_signed_url(
|
||||
mock_async_result, mock_celery, client, case
|
||||
):
|
||||
"""Success responses explicitly reveal result URLs for authorized clients."""
|
||||
job_type, payload_type, url_field = case
|
||||
signed_url = "https://storage.example.com/result?signature=deterministic-signature"
|
||||
mock_celery.backend.client.exists.return_value = True
|
||||
mock_async_result.return_value = MagicMock(
|
||||
status="SUCCESS",
|
||||
args=[{"tenant_id": "test-tenant"}],
|
||||
result={"job_id": "job", url_field: signed_url},
|
||||
)
|
||||
response = client.get(
|
||||
f"/api/v2/async-jobs/{job_type}/job",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"job_id": "job",
|
||||
"type": payload_type,
|
||||
"status": "success",
|
||||
url_field: signed_url,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
("transcribe", "transcription_data_url"),
|
||||
("summarize", "summary_data_url"),
|
||||
],
|
||||
)
|
||||
@patch("summary.api.route.tasks_v2.celery")
|
||||
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||
def test_invalid_success_result_hides_revealed_url(
|
||||
mock_async_result, mock_celery, client, case
|
||||
):
|
||||
"""Missing result fields cannot retain a raw signed URL in error details."""
|
||||
job_type, url_field = case
|
||||
canary = "private-result-signature"
|
||||
mock_celery.backend.client.exists.return_value = True
|
||||
mock_async_result.return_value = MagicMock(
|
||||
status="SUCCESS",
|
||||
args=[{"tenant_id": "test-tenant"}],
|
||||
result={url_field: f"https://storage.example.com/result?signature={canary}"},
|
||||
)
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
client.get(
|
||||
f"/api/v2/async-jobs/{job_type}/job",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
assert exc.value.errors()[0]["type"] == "missing"
|
||||
assert exc.value.errors()[0]["loc"] == ("job_id",)
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
assert canary not in str(exc.value)
|
||||
assert canary not in exc.value.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nested_error", [False, True])
|
||||
def test_missing_field_errors_preserve_raw_json_strings(client, nested_error):
|
||||
"""Raw JSON input is retained when it has not been converted to SecretStr."""
|
||||
canary = "private-validation-signature"
|
||||
signed_url = f"https://storage.example.com/recording?signature={canary}"
|
||||
payload = {"language": "en", "cloud_storage_url": signed_url}
|
||||
if nested_error:
|
||||
payload["user_sub"] = "user"
|
||||
payload["metadata"] = {"cloud_storage_url": signed_url}
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary in response.text
|
||||
assert all(error["type"] == "missing" for error in response.json()["detail"])
|
||||
expected_input = payload["metadata"] if nested_error else payload
|
||||
assert all(error["input"] == expected_input for error in response.json()["detail"])
|
||||
|
||||
@@ -22,6 +22,12 @@ def get_settings_override():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_override():
|
||||
"""Provide isolated settings containing the test tenant."""
|
||||
return get_settings_override()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
"""Provide a FastAPI TestClient for tests."""
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Secret URLs stay masked until explicitly serialized for transport."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from kombu.serialization import dumps, loads
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from summary.core import celery_worker
|
||||
from summary.core.models import (
|
||||
PushToDocsTranscriptConfig,
|
||||
RecordingMetadata,
|
||||
TranscribeTaskApiRequest,
|
||||
TranscribeTaskJob,
|
||||
)
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookSuccessPayload,
|
||||
TranscribeWebhookSuccessPayload,
|
||||
WhisperXResponse,
|
||||
)
|
||||
|
||||
CANARY = "private-signed-url-canary"
|
||||
SIGNED_URL = f"https://storage.example.com/recording.mp4?signature={CANARY}"
|
||||
METADATA = {
|
||||
"cloud_storage_url": SIGNED_URL,
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"ended_at": "2026-01-01T11:00:00Z",
|
||||
}
|
||||
DOCS_CONFIG = {
|
||||
"user_email": "user@example.com",
|
||||
"title": "Meeting",
|
||||
"download_link": SIGNED_URL,
|
||||
"form_link": SIGNED_URL,
|
||||
}
|
||||
MODEL_CASES = [
|
||||
(RecordingMetadata, METADATA, "cloud_storage_url"),
|
||||
(
|
||||
TranscribeTaskApiRequest,
|
||||
{"user_sub": "user", "language": "en", "cloud_storage_url": SIGNED_URL},
|
||||
"cloud_storage_url",
|
||||
),
|
||||
(PushToDocsTranscriptConfig, DOCS_CONFIG, "download_link"),
|
||||
(PushToDocsTranscriptConfig, DOCS_CONFIG, "form_link"),
|
||||
(
|
||||
TranscribeWebhookSuccessPayload,
|
||||
{"job_id": "job", "transcription_data_url": SIGNED_URL},
|
||||
"transcription_data_url",
|
||||
),
|
||||
(
|
||||
SummarizeWebhookSuccessPayload,
|
||||
{"job_id": "job", "summary_data_url": SIGNED_URL},
|
||||
"summary_data_url",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES)
|
||||
def test_signed_urls_are_masked_by_default(model, data, field):
|
||||
"""Reprs, ordinary dumps and JSON output mask all signed URL fields."""
|
||||
value = model.model_validate(data)
|
||||
secret = getattr(value, field)
|
||||
assert isinstance(secret, SecretStr)
|
||||
assert secret.get_secret_value() == SIGNED_URL
|
||||
assert CANARY not in repr(value)
|
||||
assert CANARY not in str(value.model_dump())
|
||||
assert CANARY not in value.model_dump_json()
|
||||
assert dump_for_transport(value)[field] == SIGNED_URL
|
||||
assert getattr(model.model_validate(value.model_dump()), field) == secret
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES)
|
||||
def test_missing_fields_do_not_echo_signed_urls(model, data, field):
|
||||
"""Root validation errors omit raw input even before secrets are wrapped."""
|
||||
required_field = (
|
||||
"job_id"
|
||||
if "job_id" in data
|
||||
else next(
|
||||
key for key in data if key not in {field, "download_link", "form_link"}
|
||||
)
|
||||
)
|
||||
invalid_data = {key: value for key, value in data.items() if key != required_field}
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate(invalid_data)
|
||||
assert CANARY not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES[:4])
|
||||
def test_invalid_signed_urls_have_sanitized_validation_messages(model, data, field):
|
||||
"""URL validation still rejects invalid URLs without quoting their value."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate({**data, field: f"not-a-url?signature={CANARY}"})
|
||||
assert CANARY not in str(exc.value)
|
||||
assert CANARY not in str(
|
||||
exc.value.errors(include_input=False, include_context=False)
|
||||
)
|
||||
assert exc.value.errors()[0]["type"] == "url_parsing"
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
|
||||
|
||||
def test_nested_task_urls_survive_broker_serialization():
|
||||
"""Nested secrets round-trip through Celery's JSON serializer as real URLs."""
|
||||
payload = TranscribeTaskJob(
|
||||
user_sub="user",
|
||||
language="en",
|
||||
cloud_storage_url=SIGNED_URL,
|
||||
tenant_id="tenant",
|
||||
received_at="2026-01-01T10:00:00Z",
|
||||
metadata=METADATA,
|
||||
push_to_docs_config=DOCS_CONFIG,
|
||||
)
|
||||
assert CANARY not in repr(payload)
|
||||
assert CANARY not in payload.model_dump_json()
|
||||
content_type, encoding, body = dumps(dump_for_transport(payload), serializer="json")
|
||||
restored = TranscribeTaskJob.model_validate(loads(body, content_type, encoding))
|
||||
assert restored.cloud_storage_url.get_secret_value() == SIGNED_URL
|
||||
assert restored.metadata.cloud_storage_url.get_secret_value() == SIGNED_URL
|
||||
assert restored.push_to_docs_config.download_link.get_secret_value() == SIGNED_URL
|
||||
assert restored.push_to_docs_config.form_link.get_secret_value() == SIGNED_URL
|
||||
|
||||
|
||||
def test_transcription_worker_uses_real_urls(monkeypatch):
|
||||
"""The worker unwraps input URLs and publishes usable signed result URLs."""
|
||||
transcription = WhisperXResponse(segments=())
|
||||
transcribe = Mock(return_value=transcription)
|
||||
monkeypatch.setattr(celery_worker, "transcribe_audio", transcribe)
|
||||
monkeypatch.setattr(celery_worker, "_should_push_to_docs", Mock(return_value=True))
|
||||
monkeypatch.setattr(
|
||||
celery_worker, "_should_auto_create_summary", Mock(return_value=False)
|
||||
)
|
||||
formatter = Mock(return_value="Formatted transcript")
|
||||
monkeypatch.setattr(celery_worker, "format_transcript", formatter)
|
||||
monkeypatch.setattr(celery_worker, "create_document_in_lasuite_docs", Mock())
|
||||
storage = Mock()
|
||||
storage.get_transcript_signed_url.return_value = SIGNED_URL
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
monkeypatch.setattr(celery_worker, "metadata_manager", Mock())
|
||||
publish = Mock()
|
||||
monkeypatch.setattr(celery_worker.call_webhook_v2_task, "apply_async", publish)
|
||||
task = celery_worker.process_audio_transcribe_v2_task
|
||||
monkeypatch.setattr(task.request, "id", "job")
|
||||
|
||||
result = task.run(
|
||||
{
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": SIGNED_URL,
|
||||
"tenant_id": "tenant",
|
||||
"received_at": "2026-01-01T10:00:00Z",
|
||||
"push_to_docs_config": DOCS_CONFIG,
|
||||
}
|
||||
)
|
||||
assert transcribe.call_args.kwargs["cloud_storage_url"] == SIGNED_URL
|
||||
assert formatter.call_args.args[3:] == (SIGNED_URL, SIGNED_URL)
|
||||
assert result["transcription_data_url"] == SIGNED_URL
|
||||
assert publish.call_args.kwargs["args"][0] == result
|
||||
|
||||
|
||||
def test_recording_metadata_url_is_unwrapped(monkeypatch):
|
||||
"""Speaker assignment fetches metadata using the real URL."""
|
||||
storage = Mock()
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
resolver = Mock()
|
||||
resolver.return_value.apply_to.return_value = {"segments": []}
|
||||
monkeypatch.setattr(celery_worker, "resolve_speaker_identities", resolver)
|
||||
celery_worker.resolve_speaker_identities_and_apply_to(
|
||||
transcription=WhisperXResponse(segments=()),
|
||||
recording_metadata=RecordingMetadata.model_validate(METADATA),
|
||||
task_id="job",
|
||||
)
|
||||
storage.read_cloud_storage_json.assert_called_once_with(SIGNED_URL)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Regression tests for observability privacy boundaries."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from summary.core import analytics, celery_worker, webhook_service
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.privacy import redact_url
|
||||
from summary.core.sentry import before_breadcrumb, before_send
|
||||
from summary.core.shared_models import SummarizeWebhookSuccessPayload
|
||||
|
||||
CANARY = "sensitive-canary"
|
||||
URL = f"https://user:password@example.com/file?signature={CANARY}#fragment"
|
||||
|
||||
|
||||
def test_url_redaction():
|
||||
"""Credentials and signed query strings are removed."""
|
||||
assert redact_url(URL) == "https://example.com/file?[redacted]"
|
||||
|
||||
|
||||
def test_sentry_scrubs_events_and_breadcrumbs():
|
||||
"""Untrusted exception messages and breadcrumb data never leave in events."""
|
||||
event = {
|
||||
"request": {"url": URL, "data": CANARY, "query_string": CANARY},
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"type": "ValueError",
|
||||
"value": CANARY,
|
||||
"stacktrace": {"frames": [{"vars": {"job_id": "job-1"}}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
"extra": {"url": URL},
|
||||
}
|
||||
result = before_send(event, {})
|
||||
assert CANARY not in str(result)
|
||||
assert result["exception"]["values"][0]["type"] == "ValueError"
|
||||
assert result["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"] == {
|
||||
"job_id": "job-1"
|
||||
}
|
||||
assert before_breadcrumb({"message": CANARY, "data": {"url": URL}}, {}) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["http", "HTTP", "hTtP", "https", "HTTPS", "hTtPs"])
|
||||
def test_sentry_url_scrubbing_is_case_insensitive(scheme):
|
||||
"""Scheme casing cannot bypass redaction of URLs nested in event strings."""
|
||||
event = {
|
||||
"extra": {
|
||||
"nested": [
|
||||
{
|
||||
"detail": (
|
||||
f"prefix <{scheme}://user:password@example.com/Path"
|
||||
f"?signature={CANARY}#fragment> suffix"
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
result = before_send(event, {})
|
||||
assert result["extra"]["nested"][0]["detail"] == (
|
||||
f"prefix <{scheme.lower()}://example.com/Path?[redacted]> suffix"
|
||||
)
|
||||
assert CANARY not in str(result)
|
||||
|
||||
|
||||
def test_llm_exception_does_not_include_upstream_message(caplog):
|
||||
"""LLM failures retain their category without logging upstream text."""
|
||||
observability = Mock(is_enabled=False)
|
||||
observability.get_openai_client.return_value.chat.completions.create.side_effect = (
|
||||
ValueError(CANARY)
|
||||
)
|
||||
with caplog.at_level(logging.DEBUG), pytest.raises(LLMException) as exc:
|
||||
LLMService(observability).call("system", CANARY, "test")
|
||||
assert CANARY not in str(exc.value)
|
||||
assert CANARY not in caplog.text
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_level", [logging.DEBUG, logging.INFO])
|
||||
def test_webhook_content_is_only_logged_at_debug(
|
||||
monkeypatch, caplog, settings_override, log_level
|
||||
):
|
||||
"""DEBUG retains response content; INFO and above only contain metadata."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"id": "document-123", "content": CANARY}
|
||||
monkeypatch.setattr(webhook_service.requests, "post", Mock(return_value=response))
|
||||
payload = SummarizeWebhookSuccessPayload(job_id="job", summary_data_url=URL)
|
||||
with caplog.at_level(log_level):
|
||||
webhook_service.call_webhook_v2(tenant_id="test-tenant", payload=payload)
|
||||
assert (
|
||||
webhook_service.requests.post.call_args.kwargs["json"]["summary_data_url"]
|
||||
== URL
|
||||
)
|
||||
assert all(
|
||||
CANARY not in record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno >= logging.INFO
|
||||
)
|
||||
if log_level == logging.DEBUG:
|
||||
assert any(
|
||||
record.levelno == logging.DEBUG and CANARY in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
else:
|
||||
assert CANARY not in caplog.text
|
||||
assert "Document document-123 submitted (HTTP 200)" in caplog.text
|
||||
|
||||
|
||||
def test_webhook_failure_keeps_retry_category(monkeypatch, settings_override):
|
||||
"""Webhook exceptions remain retryable without upstream text."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
monkeypatch.setattr(
|
||||
webhook_service.requests,
|
||||
"post",
|
||||
Mock(side_effect=requests.RequestException(URL)),
|
||||
)
|
||||
payload = SummarizeWebhookSuccessPayload(job_id="job", summary_data_url=URL)
|
||||
with pytest.raises(requests.RequestException) as exc:
|
||||
webhook_service.call_webhook_v2(tenant_id="test-tenant", payload=payload)
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
def test_analytics_failure_does_not_chain_payload(monkeypatch):
|
||||
"""Analytics errors do not expose arbitrary client exception text."""
|
||||
client = analytics.Analytics()
|
||||
client._client = Mock()
|
||||
client._client.capture.side_effect = ValueError(CANARY)
|
||||
with pytest.raises(analytics.AnalyticsException) as exc:
|
||||
client.capture("event", "user")
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_level", [logging.DEBUG, logging.INFO, logging.ERROR])
|
||||
def test_celery_log_filter_respects_log_level(log_level):
|
||||
"""Celery trace values remain available in DEBUG only."""
|
||||
record = logging.LogRecord(
|
||||
"celery.app.trace", log_level, "", 0, "%s", (CANARY,), None
|
||||
)
|
||||
record.data = {"name": "task", "id": "job", "return_value": URL}
|
||||
assert celery_worker.CeleryLogPrivacyFilter().filter(record)
|
||||
if log_level == logging.DEBUG:
|
||||
assert CANARY in record.getMessage()
|
||||
assert record.data["return_value"] == URL
|
||||
else:
|
||||
assert CANARY not in record.getMessage()
|
||||
assert record.data == {}
|
||||
|
||||
|
||||
def test_task_arguments_have_private_display_representations(monkeypatch):
|
||||
"""The actual task arguments are delivered but their event repr is hidden."""
|
||||
publish = Mock()
|
||||
monkeypatch.setattr(celery_worker.Task, "apply_async", publish)
|
||||
task = celery_worker.PrivateTask()
|
||||
task.apply_async(args=[CANARY], kwargs={"url": URL})
|
||||
assert publish.call_args.kwargs["args"] == [CANARY]
|
||||
assert publish.call_args.kwargs["argsrepr"] == "[Filtered]"
|
||||
assert publish.call_args.kwargs["kwargsrepr"] == "[Filtered]"
|
||||
|
||||
|
||||
def test_analytics_source_url_removes_signature():
|
||||
"""Task analytics keeps the source path without credentials or signature."""
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
from summary.core.models import TranscribeTaskJob # noqa: PLC0415
|
||||
|
||||
manager = analytics.MetadataManager()
|
||||
manager._is_disabled = False
|
||||
manager.has_task_id = Mock(return_value=False)
|
||||
manager._save_metadata = Mock()
|
||||
payload = TranscribeTaskJob(
|
||||
user_sub="user",
|
||||
tenant_id="tenant",
|
||||
cloud_storage_url=URL,
|
||||
language="en",
|
||||
received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
manager.create("job", payload)
|
||||
metadata = manager._save_metadata.call_args.args[1]
|
||||
assert metadata["source_url"] == "https://example.com/file?[redacted]"
|
||||
assert CANARY not in str(metadata)
|
||||
|
||||
|
||||
def test_invalid_llm_json_is_sanitized():
|
||||
"""Invalid structured LLM output retains a retryable LLM category."""
|
||||
with pytest.raises(LLMException) as exc:
|
||||
celery_worker.parse_llm_json(CANARY)
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
(None, "[redacted]"),
|
||||
("https://[invalid?secret=value", "[redacted]"),
|
||||
("https://example.com/path#fragment", "https://example.com/path"),
|
||||
("https://example.com/path?", "https://example.com/path?[redacted]"),
|
||||
],
|
||||
)
|
||||
def test_redact_url_edge_cases(url, expected):
|
||||
"""Malformed and non-string URLs do not expose credentials or query data."""
|
||||
assert redact_url(url) == expected
|
||||
@@ -1,27 +1,32 @@
|
||||
"""Tests for the Sentry configuration.
|
||||
|
||||
Each test raises an error from code handling meeting content and inspects the
|
||||
event Sentry would send: the content must be redacted, while harmless local
|
||||
variables are kept for debugging.
|
||||
event Sentry would send: sensitive content is redacted and useful locals remain.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
import requests
|
||||
import sentry_sdk
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.stub import Stubber
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
from sentry_sdk.transport import Transport
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core import file_service, webhook_service
|
||||
from summary.core import sentry as sentry_module
|
||||
from summary.core.file_service import FileService
|
||||
from summary.core.file_service import FileService, FileServiceException
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
from summary.core.shared_models import SummarizeWebhookSuccessPayload, WhisperXResponse
|
||||
|
||||
CANARY = "CANARY-MEETING-CONTENT"
|
||||
|
||||
@@ -51,8 +56,14 @@ def sentry_events() -> Iterator[SentryEvents]:
|
||||
transport=transport,
|
||||
send_default_pii=False,
|
||||
include_local_variables=True,
|
||||
before_send=sentry_module.before_send,
|
||||
before_breadcrumb=sentry_module.before_breadcrumb,
|
||||
event_scrubber=sentry_module.build_event_scrubber(),
|
||||
default_integrations=False,
|
||||
integrations=[
|
||||
LoggingIntegration(level=logging.DEBUG, event_level=None),
|
||||
StdlibIntegration(),
|
||||
],
|
||||
)
|
||||
|
||||
def flush() -> list[str]:
|
||||
@@ -63,6 +74,125 @@ def sentry_events() -> Iterator[SentryEvents]:
|
||||
sentry_sdk.init() # Disable Sentry for the following tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_recording_url() -> Iterator[str]:
|
||||
"""Serve a deterministic 404 through the real instrumented HTTP client."""
|
||||
|
||||
class MissingRecordingHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), MissingRecordingHandler) as server:
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield (
|
||||
f"http://127.0.0.1:{server.server_port}/recordings/recording-123.mp4"
|
||||
f"?X-Amz-Credential={CANARY}-credential"
|
||||
f"&X-Amz-Signature={CANARY}-signature"
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_sentry_download_404_excludes_signed_url(
|
||||
sentry_events: SentryEvents, missing_recording_url: str, caplog
|
||||
) -> None:
|
||||
"""A failed download excludes signed URLs from logs, locals and breadcrumbs."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
try:
|
||||
FileService()._download_from_cloud_storage_url(missing_recording_url)
|
||||
except FileServiceException as exc:
|
||||
assert exc.__cause__ is None
|
||||
assert exc.__suppress_context__
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("the download should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
assert CANARY not in caplog.text
|
||||
assert "/recordings/recording-123.mp4?[redacted]" in caplog.text
|
||||
parsed = json.loads(event)
|
||||
assert [item["type"] for item in parsed["exception"]["values"]] == [
|
||||
"FileServiceException"
|
||||
]
|
||||
download_vars = next(
|
||||
frame_vars for frame_vars in _local_vars(event) if "extension" in frame_vars
|
||||
)
|
||||
assert download_vars["extension"] == "'.mp4'"
|
||||
assert download_vars["cloud_storage_url"] == "[Filtered]"
|
||||
breadcrumbs = parsed["breadcrumbs"]["values"]
|
||||
assert any(crumb.get("type") == "http" for crumb in breadcrumbs)
|
||||
assert any(
|
||||
crumb.get("category") == "summary.core.file_service" for crumb in breadcrumbs
|
||||
)
|
||||
assert all("message" not in crumb and "data" not in crumb for crumb in breadcrumbs)
|
||||
|
||||
|
||||
def test_sentry_chained_http_error_excludes_signed_url(
|
||||
sentry_events: SentryEvents, missing_recording_url: str
|
||||
) -> None:
|
||||
"""Sentry also filters signed URLs in explicitly chained HTTP errors."""
|
||||
try:
|
||||
try:
|
||||
response = requests.get(missing_recording_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise FileServiceException("Download failed") from exc
|
||||
except FileServiceException:
|
||||
sentry_sdk.capture_exception()
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
exceptions = json.loads(event)["exception"]["values"]
|
||||
assert [item["type"] for item in exceptions] == [
|
||||
"HTTPError",
|
||||
"FileServiceException",
|
||||
]
|
||||
assert all(item["value"] == "[Filtered]" for item in exceptions)
|
||||
|
||||
|
||||
def test_sentry_excludes_webhook_debug_content(
|
||||
sentry_events: SentryEvents, monkeypatch, settings_override, caplog
|
||||
) -> None:
|
||||
"""Raw DEBUG responses stay in application logs and out of Sentry events."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
signed_url = f"https://storage.example.com/result?signature={CANARY}"
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {
|
||||
"id": "document-123",
|
||||
"content": CANARY,
|
||||
"url": signed_url,
|
||||
}
|
||||
monkeypatch.setattr(webhook_service.requests, "post", Mock(return_value=response))
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
webhook_service.call_webhook_v2(
|
||||
tenant_id="test-tenant",
|
||||
payload=SummarizeWebhookSuccessPayload(
|
||||
job_id="job", summary_data_url=signed_url
|
||||
),
|
||||
)
|
||||
try:
|
||||
raise RuntimeError("A later failure")
|
||||
except RuntimeError:
|
||||
sentry_sdk.capture_exception()
|
||||
|
||||
assert CANARY in caplog.text
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
assert any(
|
||||
crumb.get("category") == "summary.core.webhook_service"
|
||||
and crumb.get("level") == "debug"
|
||||
for crumb in json.loads(event)["breadcrumbs"]["values"]
|
||||
)
|
||||
|
||||
|
||||
def _transcript() -> WhisperXResponse:
|
||||
return WhisperXResponse.model_validate(
|
||||
{
|
||||
@@ -121,7 +251,7 @@ def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]:
|
||||
def test_sentry_store_transcript_failure_redacts_transcript(
|
||||
sentry_events: SentryEvents, s3_stubber: Stubber
|
||||
) -> None:
|
||||
"""A failed S3 upload does not send the transcript, but keeps the job id."""
|
||||
"""A failed S3 upload redacts the transcript while retaining useful locals."""
|
||||
s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest")
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Formatted validation errors hide input values, including after pickling."""
|
||||
|
||||
import pickle
|
||||
import traceback
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
CANARY = "private-validation-canary"
|
||||
|
||||
|
||||
def test_formatted_errors_hide_ordinary_input_after_pickle_roundtrip():
|
||||
"""Formatting hides raw input even when diagnostics intentionally retain it."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[{"type": "int_parsing", "loc": ("field",), "input": CANARY}],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
restored = pickle.loads(pickle.dumps(cleaned)) # noqa: S301 -- own test exception
|
||||
for error in (cleaned, restored):
|
||||
assert error.errors()[0]["input"] == CANARY
|
||||
assert CANARY not in str(error)
|
||||
assert CANARY not in repr(error)
|
||||
assert CANARY not in "".join(traceback.format_exception(error))
|
||||
assert error.errors()[0]["type"] == "int_parsing"
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Validation cleanup preserves diagnostics without retaining SecretStr values."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pickle
|
||||
import traceback
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import Retry
|
||||
from celery.utils.serialization import get_pickleable_exception
|
||||
from fastapi import Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
from summary.core import celery_worker
|
||||
from summary.core.exceptions import CleanedValidationError, clean_validation_errors
|
||||
from summary.main import validation_error_handler
|
||||
|
||||
CANARY = "private-validation-canary"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom", [False, True])
|
||||
def test_cleaned_validation_error_preserves_details_and_pickles(custom):
|
||||
"""Builtin and custom validation errors retain their type, location and message."""
|
||||
error_type = (
|
||||
PydanticCustomError(
|
||||
"custom_validation", "Invalid value: {reason}", {"reason": "test"}
|
||||
)
|
||||
if custom
|
||||
else "int_type"
|
||||
)
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[
|
||||
{"type": error_type, "loc": ("secret",), "input": SecretStr(CANARY)},
|
||||
{"type": "int_parsing", "loc": ("ordinary",), "input": "not-an-int"},
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
original_details = original.errors()
|
||||
details = cleaned.errors()
|
||||
assert isinstance(cleaned, ValidationError)
|
||||
assert cleaned.title == original.title
|
||||
assert cleaned.error_count() == original.error_count()
|
||||
assert details[0] == {**original_details[0], "input": "[redacted]"}
|
||||
assert details[1] == original_details[1]
|
||||
assert isinstance(original.errors()[0]["input"], SecretStr)
|
||||
assert CANARY not in str(cleaned)
|
||||
assert CANARY not in cleaned.json()
|
||||
serialized = pickle.dumps(cleaned)
|
||||
assert CANARY.encode() not in serialized
|
||||
restored = pickle.loads(serialized) # noqa: S301 -- own in-memory test exception
|
||||
assert isinstance(restored, CleanedValidationError)
|
||||
assert restored.errors() == details
|
||||
assert isinstance(get_pickleable_exception(cleaned), CleanedValidationError)
|
||||
|
||||
|
||||
def test_cleanup_masks_nested_secrets_and_preserves_plain_input():
|
||||
"""Missing-field inputs keep ordinary values while masking nested secrets."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"NestedModel",
|
||||
[
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ("required",),
|
||||
"input": {
|
||||
"ordinary": "keep me",
|
||||
"nested": [SecretStr(CANARY), {"url": SecretStr(CANARY)}],
|
||||
"tuple": (SecretStr(CANARY), 42),
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
assert cleaned.errors()[0]["input"] == {
|
||||
"ordinary": "keep me",
|
||||
"nested": ["[redacted]", {"url": "[redacted]"}],
|
||||
"tuple": ("[redacted]", 42),
|
||||
}
|
||||
assert CANARY not in cleaned.json()
|
||||
assert CANARY.encode() not in pickle.dumps(cleaned)
|
||||
|
||||
|
||||
def test_cleanup_does_not_retain_validator_exception_objects():
|
||||
"""Validator context keeps its readable message without secret-bearing args."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[
|
||||
{
|
||||
"type": "value_error",
|
||||
"loc": (),
|
||||
"input": SecretStr(CANARY),
|
||||
"ctx": {"error": ValueError(SecretStr(CANARY))},
|
||||
}
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
assert cleaned.errors()[0]["ctx"] == {"error": "**********"}
|
||||
assert CANARY.encode() not in pickle.dumps(cleaned)
|
||||
|
||||
|
||||
def test_clean_validation_errors_keeps_all_other_fields():
|
||||
"""The API cleanup preserves diagnostics and nonsecret input."""
|
||||
details = [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ("body", "field"),
|
||||
"msg": "Invalid value",
|
||||
"url": "https://example.com/errors/test",
|
||||
"ctx": {"limit": 3},
|
||||
"input": "ordinary input",
|
||||
}
|
||||
]
|
||||
assert clean_validation_errors(details) == details
|
||||
assert (
|
||||
json.loads(json.dumps(clean_validation_errors(details)))[0]["input"]
|
||||
== "ordinary input"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
[
|
||||
"call_webhook_v2_task",
|
||||
"process_audio_transcribe_v2_task",
|
||||
"summarize_v2_task",
|
||||
"task_started_transcript",
|
||||
"task_started_summary",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("secret_wrapped", [False, True])
|
||||
def test_task_validation_uses_cleaned_errors(handler_name, secret_wrapped):
|
||||
"""Task entry points and prerun signals consistently suppress the original error."""
|
||||
handler = getattr(celery_worker, handler_name)
|
||||
signed_url = f"https://storage.example.com/file?signature={CANARY}"
|
||||
value = SecretStr(signed_url) if secret_wrapped else signed_url
|
||||
payload = {"cloud_storage_url": value, "content": value}
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
if handler_name.startswith("task_started"):
|
||||
handler(task_id="job", args=[payload])
|
||||
elif handler_name == "call_webhook_v2_task":
|
||||
handler.run(
|
||||
payload={
|
||||
"type": "summary",
|
||||
"status": "success",
|
||||
"summary_data_url": value,
|
||||
},
|
||||
tenant_id="tenant",
|
||||
)
|
||||
elif handler_name == "summarize_v2_task":
|
||||
# Exercise validation without triggering the summary task's retries.
|
||||
handler._orig_run(payload=payload)
|
||||
else:
|
||||
handler.run(payload=payload)
|
||||
assert exc.value.__suppress_context__
|
||||
assert CANARY not in "".join(traceback.format_exception(exc.value))
|
||||
assert CANARY not in exc.value.json()
|
||||
assert CANARY.encode() not in pickle.dumps(exc.value)
|
||||
|
||||
|
||||
def test_upstream_transcription_validation_hides_raw_input(monkeypatch):
|
||||
"""Invalid ASR responses do not remain in structured validation errors."""
|
||||
storage = MagicMock()
|
||||
storage.prepare_audio_file.return_value.__enter__.return_value = (
|
||||
Mock(),
|
||||
{"duration": 1},
|
||||
)
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
monkeypatch.setattr(celery_worker, "metadata_manager", Mock())
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"text": {"content": CANARY}}
|
||||
monkeypatch.setattr(celery_worker.requests, "post", Mock(return_value=response))
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
celery_worker.transcribe_audio(
|
||||
task_id="job", language="en", cloud_storage_url="https://example.com/audio"
|
||||
)
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
assert CANARY not in exc.value.json()
|
||||
assert CANARY.encode() not in pickle.dumps(exc.value)
|
||||
|
||||
|
||||
def test_api_error_handler_uses_secret_aware_cleanup():
|
||||
"""API errors mask typed secrets while retaining other values and context."""
|
||||
errors = [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ("body", "field"),
|
||||
"msg": "Invalid value",
|
||||
"input": {"url": SecretStr(CANARY), "ordinary": "keep me"},
|
||||
"ctx": {"limit": 3},
|
||||
"url": "https://example.com/errors/test",
|
||||
},
|
||||
]
|
||||
response = asyncio.run(
|
||||
validation_error_handler(
|
||||
Request({"type": "http"}), RequestValidationError(errors)
|
||||
)
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert CANARY.encode() not in response.body
|
||||
assert json.loads(response.body)["detail"] == [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ["body", "field"],
|
||||
"msg": "Invalid value",
|
||||
"input": {"url": "[redacted]", "ordinary": "keep me"},
|
||||
"ctx": {"limit": 3},
|
||||
"url": "https://example.com/errors/test",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_summary_task_retries_with_the_cleaned_exception(monkeypatch):
|
||||
"""The summary task still retries validation failures using the safe exception."""
|
||||
retry = Mock(side_effect=Retry())
|
||||
monkeypatch.setattr(celery_worker.summarize_v2_task, "retry", retry)
|
||||
with pytest.raises(Retry):
|
||||
celery_worker.summarize_v2_task.run(payload={"received_at": SecretStr(CANARY)})
|
||||
error = retry.call_args.kwargs["exc"]
|
||||
assert isinstance(error, CleanedValidationError)
|
||||
assert CANARY.encode() not in pickle.dumps(error)
|
||||
Reference in New Issue
Block a user