Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6403be62e | |||
| ca3502ee4d | |||
| 8c5352103a | |||
| 3e3ee7e698 | |||
| af1c40995b | |||
| 1da0f6600e | |||
| a3fdb206ef | |||
| da4d323144 | |||
| 3e45193a7c | |||
| 7a55e31a73 | |||
| 4baef38cae | |||
| 1eba8b77c0 | |||
| 579ff98a5a | |||
| fe34b93249 | |||
| 205960106b | |||
| d685b541c5 | |||
| 834ed4226f | |||
| 3f8e105035 | |||
| 431bec3970 | |||
| 54f2762e79 | |||
| 9c438eba06 | |||
| bedb0573b8 | |||
| 9d3088d9db |
@@ -0,0 +1 @@
|
||||
nodejs 22.21.1
|
||||
@@ -20,16 +20,12 @@ and this project adheres to
|
||||
- ✨(frontend) Add stat for Crisp #1824
|
||||
- ✨(auth) add silent login #1690
|
||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||
- ✨(frontend) activate Find search #1834
|
||||
- ✨ handle searching on subdocuments #1834
|
||||
- ✨(backend) add search feature flags #1897
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
||||
- ✨(backend) add field for button label in email template #1817
|
||||
- ✨(backend) improve fallback logic on search endpoint #1834
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -43,8 +39,6 @@ and this project adheres to
|
||||
### Removed
|
||||
|
||||
- 🔥(project) remove all code related to template #1780
|
||||
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
|
||||
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -79,16 +79,10 @@ create-env-local-files:
|
||||
@touch env.d/development/kc_postgresql.local
|
||||
.PHONY: create-env-local-files
|
||||
|
||||
generate-secret-keys:
|
||||
generate-secret-keys: ## generate secret keys to be stored in common.local
|
||||
@bin/generate-oidc-store-refresh-token-key.sh
|
||||
.PHONY: generate-secret-keys
|
||||
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-local-files \
|
||||
generate-secret-keys
|
||||
create-env-local-files
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
|
||||
@@ -147,11 +147,6 @@ $ make frontend-test
|
||||
$ make frontend-lint
|
||||
```
|
||||
|
||||
Backend tests can be run without docker. This is useful to configure PyCharm of VSCode to do it.
|
||||
Removing docker for testing requires to overwrite some URL and port values that are different in and out of
|
||||
Docker. `env.d/development/common` contains all variables, some fo them having to be overwritten by those in
|
||||
`env.d/development/common.test`.
|
||||
|
||||
**Adding content**
|
||||
|
||||
You can create a basic demo site by running this command:
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Generate the secret OIDC_STORE_REFRESH_TOKEN_KEY and store it to common.local
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
COMMON_LOCAL="env.d/development/common.local"
|
||||
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY=$(openssl rand -base64 32)
|
||||
|
||||
echo "OIDC_STORE_REFRESH_TOKEN_KEY=${OIDC_STORE_REFRESH_TOKEN_KEY}" > "${COMMON_LOCAL}"
|
||||
echo "✓ OIDC_STORE_REFRESH_TOKEN_KEY generated and stored in ${COMMON_LOCAL}"
|
||||
@@ -845,6 +845,32 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "encryption",
|
||||
"name": "Encryption",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"redirectUris": [
|
||||
"http://localhost:7202/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:7202"
|
||||
],
|
||||
"frontchannelLogout": true,
|
||||
"attributes": {},
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"profile",
|
||||
"roles",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": []
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
+7
-11
@@ -24,7 +24,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 |
|
||||
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| COLLABORATION_API_URL | Collaboration api host | |
|
||||
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||
@@ -34,7 +34,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) |
|
||||
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] |
|
||||
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"]
|
||||
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_HOST | Host of the database | localhost |
|
||||
@@ -57,15 +57,15 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
|
||||
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
|
||||
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||
| DJANGO_SECRET_KEY | Secret key | |
|
||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
|
||||
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
|
||||
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_JS_URL | To add a external js file to the app | |
|
||||
| FRONTEND_JS_URL | To add a external js file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_THEME | Frontend theme to use | |
|
||||
| LANGUAGE_CODE | Default language | en-us |
|
||||
@@ -99,9 +99,6 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_STORE_ACCESS_TOKEN | If True stores OIDC access token in session. | false |
|
||||
| OIDC_STORE_REFRESH_TOKEN | If True stores OIDC refresh token in session. | false |
|
||||
| OIDC_STORE_REFRESH_TOKEN_KEY | Key to encrypt refresh token stored in session, must be a valid Fernet key | |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
@@ -111,9 +108,8 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
|
||||
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
|
||||
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
|
||||
| SEARCH_URL | Find application endpoint for search queries | |
|
||||
| SEARCH_INDEXER_SECRET | Token required for indexation queries | |
|
||||
| INDEXING_URL | Find application endpoint for indexation | |
|
||||
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
|
||||
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
|
||||
|
||||
+18
-29
@@ -1,8 +1,8 @@
|
||||
# Setup Find search for Docs
|
||||
# Setup the Find search for Impress
|
||||
|
||||
This configuration will enable Find searches:
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexing of the document into Find.
|
||||
- The `api/v1.0/documents/search/` will be used as proxy for searching documents from Find indexes.
|
||||
This configuration will enable the fulltext search feature for Docs :
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
|
||||
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
|
||||
|
||||
## Create an index service for Docs
|
||||
|
||||
@@ -15,38 +15,27 @@ See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
|
||||
|
||||
## Configure settings of Docs
|
||||
|
||||
Find uses a service provider authentication for indexing and a OIDC authentication for searching.
|
||||
|
||||
Add those Django settings to the Docs application to enable the feature.
|
||||
Add those Django settings the Docs application to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50 # Maximum number of results expected from the search endpoint
|
||||
|
||||
INDEXING_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
# Service provider authentication
|
||||
# The token from service "docs" of Find application (development).
|
||||
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
|
||||
# OIDC authentication
|
||||
OIDC_STORE_ACCESS_TOKEN=True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY="<your-32-byte-encryption-key==>"
|
||||
# Search endpoint. Uses the OIDC token for authentication
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
# Maximum number of results expected from the search endpoint
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
```
|
||||
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY` must be a valid Fernet key (32 url-safe base64-encoded bytes).
|
||||
To create one, use the `bin/generate-oidc-store-refresh-token-key.sh` command.
|
||||
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
|
||||
|
||||
## Feature flags
|
||||
|
||||
The Find search integration is controlled by two feature flags:
|
||||
- `flag_find_hybrid_search`
|
||||
- `flag_find_full_text_search`
|
||||
|
||||
If a user has both flags activated the most advanced search is used (hybrid > full text > title).
|
||||
A user with no flag will default to the basic title search.
|
||||
|
||||
Feature flags can be activated through the admin interface.
|
||||
```shell
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
```
|
||||
|
||||
@@ -51,9 +51,13 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
OIDC_STORE_ACCESS_TOKEN=True
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint and encryption service.
|
||||
OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
@@ -78,9 +82,8 @@ DOCSPEC_API_URL=http://docspec:4000/conversion
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
# Indexer
|
||||
SEARCH_INDEXER_CLASS=core.services.search_indexers.FindDocumentIndexer
|
||||
# Indexer (disabled)
|
||||
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
INDEXING_URL=http://find:8000/api/v1.0/documents/index/
|
||||
SEARCH_URL=http://find:8000/api/v1.0/documents/search/
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Test environment configuration for running tests without docker
|
||||
# Base configuration is loaded from 'common' file
|
||||
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_CONFIGURATION=Test
|
||||
DB_PORT=15432
|
||||
AWS_S3_ENDPOINT_URL=http://localhost:9000
|
||||
@@ -66,10 +66,13 @@ class ListDocumentFilter(DocumentFilter):
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
is_encrypted = django_filters.BooleanFilter(
|
||||
method="filter_is_encrypted", label=_("Encrypted")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["is_creator_me", "is_favorite", "title"]
|
||||
fields = ["is_creator_me", "is_favorite", "is_encrypted", "title"]
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
@@ -110,6 +113,24 @@ class ListDocumentFilter(DocumentFilter):
|
||||
|
||||
return queryset.filter(is_favorite=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_encrypted(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are encrypted.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_encrypted=true
|
||||
→ Filters documents encrypted
|
||||
- /api/v1.0/documents/?is_encrypted=false
|
||||
→ Filters documents not encrypted
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(is_encrypted=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_masked(self, queryset, name, value):
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,6 @@ from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
|
||||
"children": {"GET": "children_list", "POST": "children_create"},
|
||||
"search": {"GET": "retrieve"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,11 +29,12 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
full_name = serializers.SerializerMethodField(read_only=True)
|
||||
short_name = serializers.SerializerMethodField(read_only=True)
|
||||
suite_user_id = serializers.CharField(source='sub', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "language", "suite_user_id"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name", "suite_user_id"]
|
||||
|
||||
def get_full_name(self, instance):
|
||||
"""Return the full name of the user."""
|
||||
@@ -57,25 +58,33 @@ class UserLightSerializer(UserSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["full_name", "short_name"]
|
||||
read_only_fields = ["full_name", "short_name"]
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
is_encrypted = serializers.BooleanField(read_only=True)
|
||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_role = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||
accesses_user_ids = serializers.SerializerMethodField(read_only=True)
|
||||
accesses_fingerprints_per_user = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -84,8 +93,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -99,6 +110,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -107,8 +119,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -151,6 +165,38 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Return the deleted_at of the current document."""
|
||||
return instance.ancestors_deleted_at
|
||||
|
||||
def get_accesses_user_ids(self, instance):
|
||||
"""Return user IDs of members with access to this document.
|
||||
The frontend uses these to fetch public keys from the encryption service."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
return [str(uid) for uid in instance.accesses_user_ids]
|
||||
|
||||
def get_accesses_fingerprints_per_user(self, instance):
|
||||
"""Return fingerprints of users' public keys at share time."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
if not instance.is_encrypted:
|
||||
return None
|
||||
return instance.accesses_fingerprints_per_user
|
||||
|
||||
def get_encrypted_document_symmetric_key_for_user(self, instance):
|
||||
"""Return the encrypted symmetric key for the current user."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
if not instance.is_encrypted:
|
||||
return None
|
||||
try:
|
||||
access = models.DocumentAccess.objects.get(
|
||||
document=instance, user=request.user
|
||||
)
|
||||
return access.encrypted_document_symmetric_key_for_user
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
"""Minial document serializer for nesting in document accesses."""
|
||||
@@ -165,6 +211,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
contentEncrypted = serializers.BooleanField(required=False, write_only=True)
|
||||
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||
file = serializers.FileField(
|
||||
required=False, write_only=True, allow_null=True, max_length=255
|
||||
@@ -175,18 +222,23 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"content",
|
||||
"contentEncrypted",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"file",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -209,7 +261,9 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -228,6 +282,11 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
if request and request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
|
||||
# if user is not authenticated remove public keys information since he can still retrieve the document
|
||||
if request and not request.user.is_authenticated:
|
||||
fields.pop("accesses_user_ids", None)
|
||||
fields.pop("encrypted_document_symmetric_key_for_user", None)
|
||||
|
||||
return fields
|
||||
|
||||
def validate_id(self, value):
|
||||
@@ -285,7 +344,15 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"attachments" field for access control.
|
||||
"""
|
||||
content = self.validated_data.get("content", "")
|
||||
extracted_attachments = set(utils.extract_attachments(content))
|
||||
|
||||
# Encrypted content cannot be parsed as a Yjs update
|
||||
# TODO: for now skip attachment extraction for encrypted documents but we should have them
|
||||
is_encrypted = self.validated_data.get(
|
||||
"is_encrypted", self.instance and self.instance.is_encrypted
|
||||
)
|
||||
extracted_attachments = (
|
||||
set() if is_encrypted else set(utils.extract_attachments(content))
|
||||
)
|
||||
|
||||
existing_attachments = (
|
||||
set(self.instance.attachments or []) if self.instance else set()
|
||||
@@ -343,6 +410,12 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
||||
max_role = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.CharField(
|
||||
required=False, allow_blank=True, write_only=True
|
||||
)
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=False, allow_blank=True, max_length=16
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
@@ -357,6 +430,8 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"abilities",
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@@ -366,6 +441,29 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"max_role",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
"""Dynamically control field availability and requirements based on document encryption status."""
|
||||
fields = super().get_fields()
|
||||
|
||||
# Get the document from context (if available)
|
||||
document = None
|
||||
if "view" in self.context and hasattr(self.context["view"], "document"):
|
||||
document = self.context["view"].document
|
||||
|
||||
# Get the encrypted_document_symmetric_key_for_user field
|
||||
key_field = fields.get("encrypted_document_symmetric_key_for_user")
|
||||
|
||||
if key_field:
|
||||
# If document is encrypted, make the field required
|
||||
if document and getattr(document, "is_encrypted", False):
|
||||
key_field.required = True
|
||||
key_field.allow_blank = False
|
||||
# If document is not encrypted, remove the field entirely
|
||||
elif document and not getattr(document, "is_encrypted", False):
|
||||
fields.pop("encrypted_document_symmetric_key_for_user", None)
|
||||
|
||||
return fields
|
||||
|
||||
def get_abilities(self, instance) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
@@ -616,6 +714,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
"""Receive file upload requests."""
|
||||
|
||||
file = serializers.FileField()
|
||||
is_encrypted = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
@@ -626,6 +725,22 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
# For encrypted files, the content is ciphertext so MIME detection
|
||||
# is not possible. Trust the original filename extension.
|
||||
if self.initial_data.get("is_encrypted") in ("true", "True", True):
|
||||
extension = (
|
||||
file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
)
|
||||
if extension is None or len(extension) > 5:
|
||||
raise serializers.ValidationError(
|
||||
"Could not determine file extension."
|
||||
)
|
||||
self.context["expected_extension"] = extension
|
||||
self.context["content_type"] = "application/octet-stream"
|
||||
self.context["is_unsafe"] = False
|
||||
self.context["file_name"] = file.name
|
||||
return file
|
||||
|
||||
extension = file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
|
||||
# Read the first few bytes to determine the MIME type accurately
|
||||
@@ -856,6 +971,65 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class EncryptDocumentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for encrypting a document.
|
||||
|
||||
Fields:
|
||||
- content (CharField): The encrypted content of the document.
|
||||
This field is required.
|
||||
- encryptedSymmetricKeyPerUser (DictField): Mapping of user IDs to their encrypted symmetric keys.
|
||||
This field is required.
|
||||
|
||||
Example:
|
||||
Input payload for encrypting a document:
|
||||
{
|
||||
"content": "<encrypted_content>",
|
||||
"encryptedSymmetricKeyPerUser": {
|
||||
"user1_id": "encrypted_key_1",
|
||||
"user2_id": "encrypted_key_2"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
encryptedSymmetricKeyPerUser = serializers.DictField(child=serializers.CharField(), required=True)
|
||||
attachmentKeyMapping = serializers.DictField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Mapping of original attachment key to new encrypted attachment key. "
|
||||
"During encryption, existing attachments are uploaded encrypted under new keys. "
|
||||
"This mapping tells the backend to copy each new key over the original and clean up.",
|
||||
)
|
||||
|
||||
|
||||
class RemoveEncryptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for removing encryption from a document.
|
||||
|
||||
Fields:
|
||||
- content (CharField): The decrypted content of the document.
|
||||
This field is required.
|
||||
|
||||
Example:
|
||||
Input payload for removing encryption from a document:
|
||||
{
|
||||
"content": "<decrypted_content>"
|
||||
}
|
||||
"""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
attachmentKeyMapping = serializers.DictField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Mapping of old encrypted attachment key to new decrypted attachment key. "
|
||||
"During decryption, encrypted attachments are re-uploaded decrypted under new keys. "
|
||||
"This mapping tells the backend to remove the old keys and clean up.",
|
||||
)
|
||||
|
||||
|
||||
class ReactionSerializer(serializers.ModelSerializer):
|
||||
"""Serialize reactions."""
|
||||
|
||||
@@ -980,5 +1154,8 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=True, trim_whitespace=True)
|
||||
path = serializers.CharField(required=False, allow_blank=False)
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
|
||||
+413
-115
@@ -32,7 +32,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
import waffle
|
||||
from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
@@ -64,13 +63,8 @@ from core.services.search_indexers import (
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from ..enums import SearchType
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import (
|
||||
DocumentFilter,
|
||||
ListDocumentFilter,
|
||||
UserSearchFilter,
|
||||
)
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
from .throttling import (
|
||||
DocumentThrottle,
|
||||
UserListThrottleBurst,
|
||||
@@ -174,6 +168,10 @@ class UserViewSet(
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
#
|
||||
# TODO: adjust update public key
|
||||
#
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
@@ -360,6 +358,19 @@ class DocumentViewSet(
|
||||
Returns: JSON response with the translated text.
|
||||
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
|
||||
|
||||
12. **Encrypt**: Encrypt a document.
|
||||
Example: PATCH /documents/{id}/encrypt/
|
||||
Expected data:
|
||||
- content (str): The encrypted content.
|
||||
- encryptedSymmetricKeyPerUser (dict): Mapping of user IDs to encrypted symmetric keys.
|
||||
Returns: JSON response with the updated document.
|
||||
|
||||
13. **Remove Encryption**: Remove encryption from a document.
|
||||
Example: PATCH /documents/{id}/remove-encryption/
|
||||
Expected data:
|
||||
- content (str): The decrypted content.
|
||||
Returns: JSON response with the updated document.
|
||||
|
||||
### Ordering: created_at, updated_at, is_favorite, title
|
||||
|
||||
Example:
|
||||
@@ -371,11 +382,18 @@ class DocumentViewSet(
|
||||
- `is_creator_me=false`: Returns documents created by other users.
|
||||
- `is_favorite=true`: Returns documents marked as favorite by the current user
|
||||
- `is_favorite=false`: Returns documents not marked as favorite by the current user
|
||||
- `is_encrypted=true`: Returns documents encrypted
|
||||
- `is_encrypted=false`: Returns documents not encrypted
|
||||
- `title=hello`: Returns documents which title contains the "hello" string
|
||||
|
||||
Example:
|
||||
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello&is_encrypted=false
|
||||
|
||||
### Encryption Management:
|
||||
The encryption status of documents can be managed using the dedicated endpoints:
|
||||
- PATCH /documents/{id}/encrypt/ - Set is_encrypted to true
|
||||
- PATCH /documents/{id}/remove-encryption/ - Set is_encrypted to false
|
||||
|
||||
### Annotations:
|
||||
1. **is_favorite**: Indicates whether the document is marked as favorite by the current user.
|
||||
@@ -465,12 +483,14 @@ class DocumentViewSet(
|
||||
It performs early filtering on model fields, annotates user roles, and removes
|
||||
descendant documents to keep only the highest ancestors readable by the current user.
|
||||
"""
|
||||
user = request.user
|
||||
user = self.request.user
|
||||
|
||||
# Not calling filter_queryset. We do our own cooking.
|
||||
queryset = self.get_queryset()
|
||||
|
||||
filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request)
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
filter_data = filterset.form.cleaned_data
|
||||
@@ -617,6 +637,20 @@ class DocumentViewSet(
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Check rules about collaboration."""
|
||||
content_encrypted = serializer.validated_data.pop("contentEncrypted", None)
|
||||
if (
|
||||
content_encrypted is not None
|
||||
and content_encrypted != serializer.instance.is_encrypted
|
||||
):
|
||||
raise drf.exceptions.ValidationError(
|
||||
{
|
||||
"contentEncrypted": (
|
||||
"Content encryption status does not match the document's "
|
||||
"current state. Please refresh and try again."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
serializer.validated_data.get("websocket", False)
|
||||
or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
|
||||
@@ -960,6 +994,26 @@ class DocumentViewSet(
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Handle listing descendants of a document"""
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1167,6 +1221,57 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
@@ -1183,110 +1288,15 @@ class DocumentViewSet(
|
||||
"""
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
search_type = self._get_search_type()
|
||||
|
||||
if search_type == SearchType.TITLE:
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
if indexer is None:
|
||||
# fallback on title search if the indexer is not configured
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
try:
|
||||
return self._search_with_indexer(
|
||||
indexer, request, params=params, search_type=search_type
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error while searching documents with indexer: %s", e)
|
||||
# fallback on title search if the indexer is not reached
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
|
||||
def _get_search_type(self) -> SearchType:
|
||||
"""
|
||||
Returns the search type to use for the search endpoint based on feature flags.
|
||||
If a user has both flags activated the most advanced search is used
|
||||
(HYBRID > FULL_TEXT > TITLE).
|
||||
A user with no flag will default to the basic title search.
|
||||
"""
|
||||
if waffle.flag_is_active(self.request, "flag_find_hybrid_search"):
|
||||
return SearchType.HYBRID
|
||||
if waffle.flag_is_active(self.request, "flag_find_full_text_search"):
|
||||
return SearchType.FULL_TEXT
|
||||
return SearchType.TITLE
|
||||
|
||||
@staticmethod
|
||||
def _search_with_indexer(indexer, request, params, search_type):
|
||||
"""
|
||||
Returns a list of documents matching the query (q) according to the configured indexer.
|
||||
"""
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
results = indexer.search(
|
||||
q=params.validated_data["q"],
|
||||
search_type=search_type,
|
||||
token=request.session.get("oidc_access_token"),
|
||||
path=(
|
||||
params.validated_data["path"]
|
||||
if "path" in params.validated_data
|
||||
else None
|
||||
),
|
||||
visited=get_visited_document_ids_of(queryset, request.user),
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"count": len(results),
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
def _title_search(self, request, validated_data, *args, **kwargs):
|
||||
"""
|
||||
Fallback search method when no indexer is configured.
|
||||
Only searches in the title field of documents.
|
||||
"""
|
||||
request.GET = request.GET.copy()
|
||||
request.GET["title"] = validated_data["q"]
|
||||
|
||||
if "path" not in validated_data or not validated_data["path"]:
|
||||
return self.list(request, *args, **kwargs)
|
||||
|
||||
return self._list_descendants(request)
|
||||
|
||||
def _list_descendants(self, request):
|
||||
"""
|
||||
List all documents whose path starts with the provided path parameter.
|
||||
Includes the parent document itself.
|
||||
Used internally by the search endpoint when path filtering is requested.
|
||||
"""
|
||||
# Get parent document without access filtering
|
||||
parent_path = request.GET["path"]
|
||||
try:
|
||||
parent = models.Document.objects.get(path=parent_path)
|
||||
except models.Document.DoesNotExist as exc:
|
||||
raise drf.exceptions.NotFound("Document not found from path.") from exc
|
||||
|
||||
# Check object-level permissions using DocumentPermission logic
|
||||
self.check_object_permissions(request, parent)
|
||||
|
||||
# Get descendants and include the parent, ordered by path
|
||||
queryset = (
|
||||
parent.get_descendants(include_self=True)
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
# filter by title
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
return self.get_response_for_queryset(queryset)
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
@@ -1375,6 +1385,14 @@ class DocumentViewSet(
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError(
|
||||
{
|
||||
"detail": "Visibility cannot be changed for encrypted documents. "
|
||||
"Encrypted documents must remain restricted.",
|
||||
}
|
||||
)
|
||||
|
||||
# Deserialize and validate the data
|
||||
serializer = serializers.LinkDocumentSerializer(
|
||||
document, data=request.data, partial=True
|
||||
@@ -1470,18 +1488,34 @@ class DocumentViewSet(
|
||||
serializer = serializers.FileUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
ext = serializer.validated_data["expected_extension"]
|
||||
# Normally encrypted attachments would be only allowed on encrypted documents and vice-versa
|
||||
# but since during encryption/decryption we upload all attachments before the switch, we cannot enforce this rule
|
||||
is_file_encrypted = serializer.validated_data.get("is_encrypted", False)
|
||||
|
||||
# For encrypted files, set status to READY immediately since the server
|
||||
# cannot inspect ciphertext for malware scanning.
|
||||
initial_status = (
|
||||
enums.DocumentAttachmentStatus.READY
|
||||
if is_file_encrypted
|
||||
else enums.DocumentAttachmentStatus.PROCESSING
|
||||
)
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {
|
||||
"Metadata": {
|
||||
"owner": str(request.user.id),
|
||||
"status": enums.DocumentAttachmentStatus.PROCESSING,
|
||||
"status": initial_status,
|
||||
},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
|
||||
if is_file_encrypted:
|
||||
extra_args["Metadata"]["is_encrypted"] = "true"
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
ext = serializer.validated_data["expected_extension"]
|
||||
|
||||
file_unsafe = ""
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
@@ -1511,7 +1545,9 @@ class DocumentViewSet(
|
||||
document.attachments.append(key)
|
||||
document.save()
|
||||
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
# Only run malware scan for unencrypted files
|
||||
if not is_file_encrypted:
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
|
||||
url = reverse(
|
||||
"documents-media-check",
|
||||
@@ -1965,6 +2001,228 @@ class DocumentViewSet(
|
||||
}
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""
|
||||
Perform update with safety check for encryption state changes.
|
||||
|
||||
If contentEncrypted parameter is provided, it must match the current
|
||||
is_encrypted state to prevent accidental content overrides during
|
||||
encryption state transitions.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
# Prevent direct changes to is_encrypted field via PATCH
|
||||
# (encryption state should only be changed via /encrypt/ or /remove-encryption/ endpoints)
|
||||
if 'is_encrypted' in serializer.validated_data:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'is_encrypted':
|
||||
'Cannot modify is_encrypted directly. '
|
||||
'Use the /encrypt/ or /remove-encryption/ endpoints to manage encryption.'
|
||||
})
|
||||
|
||||
# Check if contentEncrypted parameter was provided
|
||||
content_encrypted = serializer.validated_data.get('contentEncrypted')
|
||||
|
||||
if content_encrypted is not None:
|
||||
# Get the current document instance
|
||||
document = self.get_object()
|
||||
|
||||
# Safety check: contentEncrypted must match current is_encrypted state
|
||||
if content_encrypted != document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'contentEncrypted':
|
||||
f'contentEncrypted must match current encryption state. '
|
||||
f'Current: is_encrypted={document.is_encrypted}, '
|
||||
f'Provided: contentEncrypted={content_encrypted}'
|
||||
})
|
||||
|
||||
# Proceed with normal update
|
||||
return super().perform_update(serializer)
|
||||
|
||||
@transaction.atomic
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["patch"],
|
||||
name="Encrypt a document",
|
||||
url_path="encrypt",
|
||||
)
|
||||
def encrypt(self, request, *args, **kwargs):
|
||||
"""
|
||||
PATCH /api/v1.0/documents/<resource_id>/encrypt/
|
||||
with expected data:
|
||||
- content: str (encrypted content)
|
||||
- encryptedSymmetricKeyPerUser: dict (user_id -> encrypted_key)
|
||||
Updates the document's content and marks it as encrypted.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.EncryptDocumentSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
encryptedSymmetricKeyPerUser = serializer.validated_data["encryptedSymmetricKeyPerUser"]
|
||||
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
|
||||
|
||||
# Prevent encryption if the document is not restricted (private)
|
||||
if document.computed_link_reach != models.LinkReachChoices.RESTRICTED:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document that is not private. '
|
||||
'Please set the document access to "Restricted" before encrypting.'
|
||||
})
|
||||
|
||||
# Prevent encryption if there are pending invitations
|
||||
if document.invitations.exists():
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document with pending invitations. '
|
||||
'Please resolve all invitations before encrypting.'
|
||||
})
|
||||
|
||||
# Validate that all users with access have an encryption public key
|
||||
document_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document, user__isnull=False
|
||||
).select_related('user')
|
||||
|
||||
users_without_public_key = [
|
||||
access.user.email or str(access.user_id)
|
||||
for access in document_accesses
|
||||
if not access.user.encryption_public_key
|
||||
]
|
||||
if users_without_public_key:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document when some members have not enabled '
|
||||
'encryption: ' + ', '.join(users_without_public_key) + '. '
|
||||
'All members must enable encryption in their account settings first.'
|
||||
})
|
||||
|
||||
# Validate that we have encrypted symmetric keys for all users with access
|
||||
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC `sub`
|
||||
users_with_access = {str(access.user.sub) for access in document_accesses}
|
||||
|
||||
# Check that encryptedSymmetricKeyPerUser contains all required users
|
||||
provided_user_ids = set(encryptedSymmetricKeyPerUser.keys())
|
||||
missing_users = users_with_access - provided_user_ids
|
||||
|
||||
if missing_users:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
f'Missing encrypted keys for users with document access: {missing_users}. '
|
||||
f'All users must have encrypted symmetric keys when encrypting a document.'
|
||||
})
|
||||
|
||||
# Check for extra users that don't have access
|
||||
extra_users = provided_user_ids - users_with_access
|
||||
if extra_users:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
f'Encrypted keys provided for users without document access: {extra_users}. '
|
||||
f'Only users with access should have encrypted symmetric keys.'
|
||||
})
|
||||
|
||||
# Remove old unencrypted attachment keys from the allowed list.
|
||||
# The frontend uploaded encrypted copies under new keys and updated the
|
||||
# Yjs content to reference them.
|
||||
if attachment_key_mapping:
|
||||
old_keys = set(attachment_key_mapping.keys())
|
||||
document.attachments = [
|
||||
k for k in (document.attachments or []) if k not in old_keys
|
||||
]
|
||||
|
||||
# Update the document content and encryption status
|
||||
document.content = content # This will be cached and saved to object storage
|
||||
document.is_encrypted = True
|
||||
document.save()
|
||||
|
||||
# Clean up old S3 objects only after the DB transaction has committed,
|
||||
# so a deletion failure can never affect the encrypt operation.
|
||||
if attachment_key_mapping:
|
||||
def _cleanup_old_attachments():
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
for old_key in attachment_key_mapping:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket_name, Key=old_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete old attachment %s", old_key)
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Store the encrypted symmetric keys in DocumentAccess for each user
|
||||
# Keys are keyed by the user's OIDC `sub`, so look up by user__sub
|
||||
for sub, encrypted_key in encryptedSymmetricKeyPerUser.items():
|
||||
try:
|
||||
# Find the DocumentAccess record for this user and document
|
||||
access = models.DocumentAccess.objects.get(document=document, user__sub=sub)
|
||||
access.encrypted_document_symmetric_key_for_user = encrypted_key
|
||||
access.save()
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
# This should not happen due to our validation above, but keep as safety
|
||||
pass
|
||||
|
||||
# Return the updated document
|
||||
serializer = self.get_serializer(document)
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@transaction.atomic
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["patch"],
|
||||
name="Remove encryption from a document",
|
||||
url_path="remove-encryption",
|
||||
)
|
||||
def remove_encryption(self, request, *args, **kwargs):
|
||||
"""
|
||||
PATCH /api/v1.0/documents/<resource_id>/remove-encryption/
|
||||
with expected data:
|
||||
- content: str (decrypted content)
|
||||
Updates the document's content and marks it as not encrypted.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.RemoveEncryptionSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
|
||||
|
||||
# Remove old encrypted attachment keys from the allowed list.
|
||||
# The frontend uploaded decrypted copies under new keys and updated
|
||||
# the Yjs content to reference them.
|
||||
if attachment_key_mapping:
|
||||
old_keys = set(attachment_key_mapping.keys())
|
||||
document.attachments = [
|
||||
k for k in (document.attachments or []) if k not in old_keys
|
||||
]
|
||||
|
||||
# Update the document content and encryption status
|
||||
document.content = content # This will be cached and saved to object storage
|
||||
document.is_encrypted = False
|
||||
document.save()
|
||||
|
||||
# Clean up any stored encrypted keys
|
||||
models.DocumentAccess.objects.filter(document=document).update(
|
||||
encrypted_document_symmetric_key_for_user=None
|
||||
)
|
||||
|
||||
# Clean up old S3 objects only after the DB transaction has committed
|
||||
if attachment_key_mapping:
|
||||
def _cleanup_old_attachments():
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
for old_key in attachment_key_mapping:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket_name, Key=old_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete old attachment %s", old_key)
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Return the updated document
|
||||
serializer = self.get_serializer(document)
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -2126,6 +2384,16 @@ class DocumentAccessViewSet(
|
||||
"Only owners of a document can assign other users as owners."
|
||||
)
|
||||
|
||||
# Handle encrypted_document_symmetric_key_for_user during creation
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
if not self.document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encrypted_document_symmetric_key_for_user':
|
||||
'This field can only be provided when the document is encrypted.'
|
||||
})
|
||||
# For encrypted documents, allow the key to be provided
|
||||
# The key will be stored directly in the DocumentAccess record
|
||||
|
||||
access = serializer.save(document_id=self.kwargs["resource_id"])
|
||||
|
||||
if access.user:
|
||||
@@ -2140,6 +2408,14 @@ class DocumentAccessViewSet(
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update an access to the document and notify the collaboration server."""
|
||||
# Prevent direct modification of encrypted_document_symmetric_key_for_user
|
||||
# This field should only be managed at access creation or when rotating the document key
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encrypted_document_symmetric_key_for_user':
|
||||
'This field cannot be modified directly.'
|
||||
})
|
||||
|
||||
access = serializer.save()
|
||||
|
||||
access_user_id = None
|
||||
@@ -2249,6 +2525,15 @@ class InvitationViewset(
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
# Prevent invitation creation for encrypted documents
|
||||
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot create invitations for encrypted documents. '
|
||||
'All invitations must be resolved before encrypting a document.'
|
||||
})
|
||||
|
||||
invitation = serializer.save()
|
||||
|
||||
invitation.document.send_invitation_email(
|
||||
@@ -2258,6 +2543,19 @@ class InvitationViewset(
|
||||
self.request.user.language or settings.LANGUAGE_CODE,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update an invitation to a document."""
|
||||
# Prevent invitation updates for encrypted documents
|
||||
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot update invitations for encrypted documents. '
|
||||
'All invitations must be resolved before encrypting a document.'
|
||||
})
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
|
||||
class DocumentAskForAccessViewSet(
|
||||
drf.mixins.ListModelMixin,
|
||||
|
||||
@@ -3,7 +3,7 @@ Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
from enum import Enum, StrEnum
|
||||
from enum import StrEnum
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
@@ -46,15 +46,3 @@ class DocumentAttachmentStatus(StrEnum):
|
||||
|
||||
PROCESSING = "processing"
|
||||
READY = "ready"
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
"""
|
||||
Defines the possible search types for a document search query.
|
||||
- TITLE: DRF based search in the title of the documents only.
|
||||
- HYBRID and FULL_TEXT: more advanced search based on Find indexer.
|
||||
"""
|
||||
|
||||
TITLE = "title"
|
||||
HYBRID = "hybrid"
|
||||
FULL_TEXT = "full-text"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.10 on 2026-02-23 10:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_remove_templateaccess_template_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='is_encrypted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='documentaccess',
|
||||
name='encrypted_document_symmetric_key_for_user',
|
||||
field=models.TextField(blank=True, help_text='Encrypted symmetric key for this document, specific to this user.', null=True, verbose_name='encrypted document symmetric key'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='encryption_public_key',
|
||||
field=models.TextField(blank=True, help_text='Public key for end-to-end encryption.', null=True, verbose_name='encryption public key'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add encryption_public_key_fingerprint to BaseAccess (DocumentAccess).
|
||||
|
||||
Stores the fingerprint of the user's public key at the time of sharing,
|
||||
allowing the frontend to detect key changes without relying solely on
|
||||
client-side TOFU. If the user's current key fingerprint differs from
|
||||
this stored value, the document access needs re-encryption.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0029_document_is_encrypted_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="documentaccess",
|
||||
name="encryption_public_key_fingerprint",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Fingerprint of the user's public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key "
|
||||
"fingerprint differs from this value, the access needs re-encryption."
|
||||
),
|
||||
max_length=16,
|
||||
null=True,
|
||||
verbose_name="encryption public key fingerprint",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Remove encryption_public_key from User model.
|
||||
|
||||
Public keys are now managed by the centralized encryption service.
|
||||
Products should fetch public keys from the encryption service's API
|
||||
when needed (e.g. for encrypting a document for multiple users).
|
||||
|
||||
The fingerprint of the public key at share time is stored on
|
||||
DocumentAccess.encryption_public_key_fingerprint (added in 0030).
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0030_baseaccess_encryption_public_key_fingerprint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="encryption_public_key",
|
||||
),
|
||||
]
|
||||
@@ -279,6 +279,23 @@ class BaseAccess(BaseModel):
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
encrypted_document_symmetric_key_for_user = models.TextField(
|
||||
_("encrypted document symmetric key"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Encrypted symmetric key for this document, specific to this user."),
|
||||
)
|
||||
encryption_public_key_fingerprint = models.CharField(
|
||||
_("encryption public key fingerprint"),
|
||||
max_length=16,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Fingerprint of the user's public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key "
|
||||
"fingerprint differs from this value, the access needs re-encryption."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -361,6 +378,7 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
|
||||
excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True)
|
||||
is_encrypted = models.BooleanField(default=False)
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
@@ -718,6 +736,39 @@ class Document(MP_Node, BaseModel):
|
||||
"""Actual link role on the document."""
|
||||
return self.computed_link_definition["link_role"]
|
||||
|
||||
@property
|
||||
def accesses_user_ids(self):
|
||||
"""
|
||||
Return the list of user IDs with access to this document.
|
||||
The frontend uses these IDs to fetch public keys from the
|
||||
centralized encryption service.
|
||||
"""
|
||||
return list(
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False)
|
||||
.values_list('user__sub', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@property
|
||||
def accesses_fingerprints_per_user(self):
|
||||
"""
|
||||
Return the fingerprint of each user's public key at the time of sharing.
|
||||
This allows the frontend to detect key changes by comparing the
|
||||
fingerprint stored at share time with the current public key fingerprint.
|
||||
"""
|
||||
accesses = (
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False, encryption_public_key_fingerprint__isnull=False)
|
||||
.values_list('user__sub', 'encryption_public_key_fingerprint')
|
||||
)
|
||||
|
||||
return {
|
||||
str(sub): fingerprint
|
||||
for sub, fingerprint in accesses
|
||||
if fingerprint
|
||||
}
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
@@ -797,12 +848,14 @@ class Document(MP_Node, BaseModel):
|
||||
"descendants": can_get,
|
||||
"destroy": can_destroy,
|
||||
"duplicate": can_get and user.is_authenticated,
|
||||
"encrypt": is_owner_or_admin,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"invite_owner": is_owner and not is_deleted,
|
||||
"mask": can_get and user.is_authenticated,
|
||||
"move": is_owner_or_admin and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"remove_encryption": is_owner_or_admin,
|
||||
"restore": is_owner,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": can_get,
|
||||
|
||||
@@ -8,12 +8,12 @@ from functools import cache
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.enums import SearchType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_batch_accesses_by_users_and_teams(paths):
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
@@ -78,9 +78,7 @@ def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
visited_ids = models.LinkTrace.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
@@ -88,12 +86,12 @@ def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=visited_ids)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return tuple(str(id) for id in docs.values_list("pk", flat=True))
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
@@ -109,13 +107,15 @@ class BaseDocumentIndexer(ABC):
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.INDEXING_URL
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_URL
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured("INDEXING_URL must be set in Django settings.")
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
@@ -123,7 +123,9 @@ class BaseDocumentIndexer(ABC):
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
def index(self, queryset=None, batch_size=None):
|
||||
"""
|
||||
@@ -182,16 +184,8 @@ class BaseDocumentIndexer(ABC):
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
self,
|
||||
q: str,
|
||||
token: str,
|
||||
visited: tuple[str, ...] = (),
|
||||
nb_results: int = None,
|
||||
path: str = None,
|
||||
search_type: SearchType = None,
|
||||
):
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), nb_results=None):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
@@ -199,7 +193,7 @@ class BaseDocumentIndexer(ABC):
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
q (str): user query.
|
||||
text (str): Text search content.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
@@ -207,27 +201,21 @@ class BaseDocumentIndexer(ABC):
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
Defaults to 50 if not specified.
|
||||
path (str, optional):
|
||||
The parent path to search descendants of.
|
||||
search_type:
|
||||
hybrid or full-text search
|
||||
"""
|
||||
nb_results = nb_results or self.search_limit
|
||||
results = self.search_query(
|
||||
response = self.search_query(
|
||||
data={
|
||||
"q": q,
|
||||
"q": text,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
"search_type": search_type,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return results
|
||||
return [d["_id"] for d in response]
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
@@ -238,76 +226,11 @@ class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
|
||||
|
||||
class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that indexes and searches documents with La Suite Find app.
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
self,
|
||||
q: str,
|
||||
token: str,
|
||||
visited: tuple[()] = (),
|
||||
nb_results: int = None,
|
||||
path: str = None,
|
||||
search_type: SearchType = None,
|
||||
):
|
||||
"""format Find search results"""
|
||||
search_results = (
|
||||
super()
|
||||
.search(
|
||||
q=q,
|
||||
token=token,
|
||||
visited=visited,
|
||||
nb_results=nb_results,
|
||||
path=path,
|
||||
search_type=search_type,
|
||||
)
|
||||
.get("results", [])
|
||||
)
|
||||
return [
|
||||
{
|
||||
**hit["_source"],
|
||||
"id": hit["_id"],
|
||||
"title": self.get_title(hit["_source"]),
|
||||
}
|
||||
for hit in search_results
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_title(source):
|
||||
"""
|
||||
Find returns the titles with an extension depending on the language.
|
||||
This function extracts the title in a generic way.
|
||||
|
||||
Handles multiple cases:
|
||||
- Localized title fields like "title.<some_extension>"
|
||||
- Fallback to plain "title" field if localized version not found
|
||||
- Returns empty string if no title field exists
|
||||
|
||||
Args:
|
||||
source (dict): The _source dictionary from a search hit
|
||||
|
||||
Returns:
|
||||
str: The extracted title or empty string if not found
|
||||
|
||||
Example:
|
||||
>>> get_title({"title.fr": "Bonjour", "id": 1})
|
||||
"Bonjour"
|
||||
>>> get_title({"title": "Hello", "id": 1})
|
||||
"Hello"
|
||||
>>> get_title({"id": 1})
|
||||
""
|
||||
"""
|
||||
titles = utils.get_value_by_pattern(source, r"^title\.")
|
||||
for title in titles:
|
||||
if title:
|
||||
return title
|
||||
if "title" in source:
|
||||
return source["title"]
|
||||
return ""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
@@ -321,7 +244,12 @@ class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
# Encrypted content is ciphertext and it should never be indexed for search
|
||||
if document.is_encrypted:
|
||||
text_content = ""
|
||||
else:
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
|
||||
@@ -63,7 +63,7 @@ def batch_document_indexer_task(timestamp):
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(document):
|
||||
def trigger_batch_document_indexer(item):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
@@ -82,14 +82,14 @@ def trigger_batch_document_indexer(document):
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
document.updated_at.isoformat(),
|
||||
item.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[document.updated_at], countdown=countdown
|
||||
args=[item.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", document.pk)
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[document.pk])
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -19,7 +19,7 @@ from core.services.search_indexers import FindDocumentIndexer
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
@@ -36,7 +36,7 @@ def test_index():
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
@@ -39,10 +39,12 @@ def indexer_settings_fixture(settings):
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.INDEXING_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_URL = "http://localhost:8081/api/v1.0/documents/search/"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
@@ -351,6 +351,7 @@ def test_api_documents_all_format():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
|
||||
@@ -46,6 +46,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -69,6 +70,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -122,6 +124,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -145,6 +148,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -217,6 +221,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -240,6 +245,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -298,6 +304,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -321,6 +328,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -406,6 +414,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -429,6 +438,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -490,6 +500,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -513,6 +524,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -626,6 +638,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -649,6 +662,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
|
||||
+70
-196
@@ -1,6 +1,5 @@
|
||||
"""
|
||||
Tests for search API endpoint in impress's core app when indexer is not
|
||||
available and a path param is given.
|
||||
Tests for Documents API endpoint in impress's core app: descendants
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -11,61 +10,26 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_indexer(indexer_settings):
|
||||
"""Disable search indexer for all tests in this file."""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public", title="doc parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="doc child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="doc grand child")
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
@@ -79,6 +43,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -104,6 +69,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -127,6 +93,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -146,60 +113,26 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(
|
||||
link_reach="public", title="grand parent doc"
|
||||
)
|
||||
grand_parent = factories.DocumentFactory(link_reach="public")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent,
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
title="parent doc",
|
||||
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
parent=parent,
|
||||
title="document",
|
||||
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child doc"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child doc")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
@@ -213,6 +146,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -236,6 +170,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -259,6 +194,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -278,13 +214,11 @@ def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(re
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(title="parent", link_reach=reach)
|
||||
child = factories.DocumentFactory(title="child", parent=document)
|
||||
_grand_child = factories.DocumentFactory(title="grand child", parent=child)
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
@@ -304,18 +238,17 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, title="parent")
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
2, parent=document, link_reach="restricted"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
@@ -335,6 +268,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -358,6 +292,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -381,6 +316,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -408,23 +344,17 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", parent=parent, title="document"
|
||||
)
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
2, parent=document, link_reach="restricted"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -445,6 +375,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -468,6 +399,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -491,6 +423,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -514,18 +447,15 @@ def test_api_documents_descendants_list_authenticated_unrelated_restricted():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
_grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
@@ -542,19 +472,17 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(title="parent")
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -575,6 +503,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -598,6 +527,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -621,6 +551,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -645,27 +576,21 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
parent=parent, link_reach="restricted", title="document"
|
||||
)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -686,6 +611,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -709,6 +635,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -732,6 +659,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -763,7 +691,7 @@ def test_api_documents_descendants_list_authenticated_related_child():
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
@@ -784,15 +712,12 @@ def test_api_documents_descendants_list_authenticated_related_team_none(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="document")
|
||||
factories.DocumentFactory.create_batch(2, parent=document, title="child")
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
@@ -812,17 +737,13 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
@@ -844,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -867,6 +789,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -890,6 +813,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -902,53 +826,3 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("", 7), # Empty string
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_search_on_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=parent)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": query, "path": parent.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_documents_descendants_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should be ignored.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
expected_ids = {
|
||||
str(document.id)
|
||||
for document in factories.DocumentFactory.create_batch(2, parent=document)
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 6), # Empty string
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=document)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -71,6 +71,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": True,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
|
||||
@@ -73,6 +73,7 @@ def test_api_documents_list_format():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": True,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
|
||||
@@ -312,6 +312,69 @@ def test_api_documents_list_filter_is_favorite_invalid():
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_encrypted
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_true():
|
||||
"""
|
||||
Authenticated users should be able to filter encrypted documents.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are encrypted
|
||||
for result in results:
|
||||
assert result["is_encrypted"] is True
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents not encrypted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are not encrypted
|
||||
for result in results:
|
||||
assert result["is_encrypted"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_invalid():
|
||||
"""Filtering with an invalid `is_encrypted` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_masked
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -151,6 +152,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -260,6 +262,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -343,6 +346,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -458,6 +462,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 2,
|
||||
@@ -541,6 +546,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 2,
|
||||
@@ -698,6 +704,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
@@ -765,6 +772,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
@@ -832,6 +840,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: search
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.test import APIClient
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from core import factories
|
||||
from core.enums import SearchType
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_flag_find_hybrid_search():
|
||||
"""Enable flag_find_hybrid_search for all tests in this module."""
|
||||
with override_flag("flag_find_hybrid_search", active=True):
|
||||
yield
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
"""
|
||||
Anonymous users should be allowed to search documents with Find.
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
# mock Find response
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
@@ -42,23 +48,7 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
status=200,
|
||||
)
|
||||
|
||||
q = "alpha"
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert search_query.call_count == 1
|
||||
assert search_query.call_args[1] == {
|
||||
"data": {
|
||||
"q": q,
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": None,
|
||||
"search_type": SearchType.HYBRID,
|
||||
},
|
||||
"token": None,
|
||||
}
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -69,151 +59,23 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
}
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
||||
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
When indexer is not configured and no path is provided,
|
||||
should fall back on list method
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = None
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked list result"}],
|
||||
}
|
||||
mock_list.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert mock_list.call_count == 1
|
||||
assert mock_list.call_args[0][0].GET.get("title") == q
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||
mock_list_descendants, indexer_settings
|
||||
):
|
||||
"""
|
||||
When indexer is not configured and path parameter is provided,
|
||||
should call _list_descendants() method
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked _list_descendants result"}],
|
||||
}
|
||||
mock_list_descendants.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
||||
)
|
||||
|
||||
assert mock_list_descendants.call_count == 1
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("title") == q
|
||||
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
|
||||
def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings):
|
||||
"""
|
||||
When indexer is configured but crashes -> falls back on title_search
|
||||
"""
|
||||
# indexer is properly configured
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
# but returns an error when the query is sent
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[{"error": "Some indexer error"}],
|
||||
status=404,
|
||||
)
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked title_search result"}],
|
||||
}
|
||||
mock_title_search.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path}
|
||||
)
|
||||
|
||||
# the search endpoint did not crash
|
||||
assert response.status_code == 200
|
||||
# fallback on title_search
|
||||
assert mock_title_search.call_count == 1
|
||||
assert mock_title_search.call_args[0][0].GET.get("q") == q
|
||||
assert mock_title_search.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_success(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
document = {"id": "doc-123", "title": "alpha", "path": "path/to/alpha.pdf"}
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{
|
||||
"_id": str(document["id"]),
|
||||
"_source": {"title": document["title"], "path": document["path"]},
|
||||
},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
@@ -223,6 +85,341 @@ def test_api_documents_search_success(indexer_settings):
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert results == [
|
||||
{"id": document["id"], "title": document["title"], "path": document["path"]}
|
||||
]
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
|
||||
@@ -54,6 +54,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -78,6 +79,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -102,6 +104,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": sibling1.excerpt,
|
||||
"id": str(sibling1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling1.is_encrypted,
|
||||
"link_reach": sibling1.link_reach,
|
||||
"link_role": sibling1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -126,6 +129,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": sibling2.excerpt,
|
||||
"id": str(sibling2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling2.is_encrypted,
|
||||
"link_reach": sibling2.link_reach,
|
||||
"link_role": sibling2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -146,6 +150,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 3,
|
||||
@@ -219,6 +224,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -243,6 +249,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -271,6 +278,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -293,6 +301,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -319,6 +328,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -341,6 +351,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -421,6 +432,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -443,6 +455,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -467,6 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -487,6 +501,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -565,6 +580,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -589,6 +605,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -617,6 +634,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -639,6 +657,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -665,6 +684,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -687,6 +707,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -769,6 +790,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -791,6 +813,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -815,6 +838,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -835,6 +859,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -917,6 +942,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -941,6 +967,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -969,6 +996,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -991,6 +1019,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -1017,6 +1046,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1039,6 +1069,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -1129,6 +1160,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1151,6 +1183,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -1175,6 +1208,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1195,6 +1229,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
|
||||
+23
-98
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Unit tests for FindDocumentIndexer
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
@@ -12,8 +12,7 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.enums import SearchType
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,7 +30,7 @@ def reset_throttle():
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
@@ -42,7 +41,7 @@ def test_models_documents_post_save_indexer(mock_push):
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
|
||||
@@ -65,14 +64,14 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
@@ -92,7 +91,7 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
assert cache.get("file-batch-indexer-throttle") is None
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
@@ -107,13 +106,13 @@ def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_se
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.INDEXING_URL = None
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -124,7 +123,7 @@ def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
@@ -146,7 +145,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
@@ -159,7 +158,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
@@ -208,7 +207,7 @@ def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
@@ -245,14 +244,14 @@ def test_models_documents_indexer_hard_deleted():
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@@ -309,7 +308,7 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert len(data) == 2
|
||||
@@ -338,16 +337,16 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
@@ -360,7 +359,7 @@ def test_models_documents_post_save_indexer_throttle():
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
@@ -390,7 +389,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
@@ -399,7 +398,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -427,7 +426,7 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -440,77 +439,3 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "search_query")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_find_document_indexer_search(mock_search_query):
|
||||
"""Test search function of FindDocumentIndexer returns formatted results"""
|
||||
|
||||
# Mock API response from Find
|
||||
hits = [
|
||||
{
|
||||
"_id": "doc-123",
|
||||
"_source": {
|
||||
"title": "Test Document",
|
||||
"content": "This is test content",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"path": "/some/path/doc-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
"_id": "doc-456",
|
||||
"_source": {
|
||||
"title.fr": "Document de test",
|
||||
"content": "Contenu de test",
|
||||
"updated_at": "2024-01-02T00:00:00Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
mock_search_query.return_value = hits
|
||||
|
||||
q = "test"
|
||||
token = "fake-token"
|
||||
nb_results = 10
|
||||
path = "/some/path/"
|
||||
visited = ["doc-123"]
|
||||
search_type = SearchType.HYBRID
|
||||
results = FindDocumentIndexer().search(
|
||||
q=q,
|
||||
token=token,
|
||||
nb_results=nb_results,
|
||||
path=path,
|
||||
visited=visited,
|
||||
search_type=search_type,
|
||||
)
|
||||
|
||||
mock_search_query.assert_called_once()
|
||||
call_args = mock_search_query.call_args
|
||||
assert call_args[1]["data"] == {
|
||||
"q": q,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
"search_type": search_type,
|
||||
}
|
||||
|
||||
assert len(results) == 2
|
||||
assert results == [
|
||||
{
|
||||
"id": hits[0]["_id"],
|
||||
"title": hits[0]["_source"]["title"],
|
||||
"content": hits[0]["_source"]["content"],
|
||||
"updated_at": hits[0]["_source"]["updated_at"],
|
||||
"path": hits[0]["_source"]["path"],
|
||||
},
|
||||
{
|
||||
"id": hits[1]["_id"],
|
||||
"title": hits[1]["_source"]["title.fr"],
|
||||
"title.fr": hits[1]["_source"]["title.fr"], # <- Find response artefact
|
||||
"content": hits[1]["_source"]["content"],
|
||||
"updated_at": hits[1]["_source"]["updated_at"],
|
||||
},
|
||||
]
|
||||
@@ -15,7 +15,7 @@ from requests import HTTPError
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
FindDocumentIndexer,
|
||||
SearchIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
@@ -78,41 +78,41 @@ def test_services_search_indexer_is_configured(indexer_settings):
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.FindDocumentIndexer"
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_indexing_url_is_none(indexer_settings):
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if INDEXING_URL is None or empty.
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.INDEXING_URL = None
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
SearchIndexer()
|
||||
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_indexing_url_is_empty(indexer_settings):
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if INDEXING_URL is empty string.
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
"""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
SearchIndexer()
|
||||
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
@@ -122,7 +122,7 @@ def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
@@ -136,35 +136,39 @@ def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_url_is_none(indexer_settings):
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_URL is None.
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = None
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_url_is_empty(indexer_settings):
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_URL is empty.
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_URL = ""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -188,7 +192,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
}
|
||||
}
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
@@ -217,7 +221,7 @@ def test_services_search_indexers_serialize_document_deleted():
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
@@ -228,13 +232,25 @@ def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_encrypted():
|
||||
"""Encrypted documents should have empty content to avoid indexing ciphertext."""
|
||||
document = factories.DocumentFactory(is_encrypted=True)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["size"] == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
@@ -242,7 +258,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.INDEXING_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -252,10 +268,10 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
FindDocumentIndexer().index()
|
||||
SearchIndexer().index()
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
@@ -272,7 +288,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert FindDocumentIndexer().index() == 5
|
||||
assert SearchIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -295,7 +311,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
"""
|
||||
@@ -310,7 +326,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert FindDocumentIndexer().index(batch_size=2) == 5
|
||||
assert SearchIndexer().index(batch_size=2) == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -333,7 +349,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
@@ -345,7 +361,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
@@ -361,7 +377,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
|
||||
"""
|
||||
Documents indexing batch can be empty if all the docs are empty.
|
||||
@@ -373,14 +389,14 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert FindDocumentIndexer().index() == 1
|
||||
assert SearchIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
assert results == [str(document.id)]
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
@@ -391,7 +407,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert FindDocumentIndexer().index() == 4
|
||||
assert SearchIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
@@ -401,7 +417,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
@@ -411,7 +427,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -424,7 +440,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
@@ -432,7 +448,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -447,9 +463,9 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.INDEXING_URL = "http://example.com/index"
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
@@ -460,7 +476,7 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.INDEXING_URL
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
@@ -494,7 +510,7 @@ def test_get_visited_document_ids_of():
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == (str(doc1.pk),)
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -528,7 +544,7 @@ def test_get_visited_document_ids_of_deleted():
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == (str(doc.pk),)
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
|
||||
|
||||
@responses.activate
|
||||
@@ -538,7 +554,9 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_URL = "http://app-find/api/v1.0/documents/search/"
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -548,17 +566,17 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
FindDocumentIndexer().search(q="alpha", token="mytoken")
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_URL with the
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -572,11 +590,11 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken")
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
@@ -599,7 +617,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
|
||||
|
||||
user = factories.UserFactory()
|
||||
indexer = FindDocumentIndexer()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -613,65 +631,17 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken")
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert kwargs.get("json")["nb_results"] == 25
|
||||
|
||||
# The argument overrides the setting value
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert kwargs.get("json")["nb_results"] == 109
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_localized_field():
|
||||
"""Test extracting title from localized title field."""
|
||||
source = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_localized_fields():
|
||||
"""Test that first matching localized title is returned."""
|
||||
source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result in ["Bonjour", "Hello"]
|
||||
|
||||
|
||||
def test_search_indexer_get_title_fallback_to_plain_title():
|
||||
"""Test fallback to plain 'title' field when no localized field exists."""
|
||||
source = {"title": "Hello World", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Hello World"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_no_title_field():
|
||||
"""Test that empty string is returned when no title field exists."""
|
||||
source = {"id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_empty_localized_title():
|
||||
"""Test that fallback works when localized title is empty."""
|
||||
source = {"title.extension": "", "title": "Fallback Title", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Fallback Title"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_extension():
|
||||
"""Test extracting title from title field with multiple extensions."""
|
||||
source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
@@ -100,38 +100,3 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"000100020005": {"000100020005"},
|
||||
"00010003": {"00010003"},
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_matching_key():
|
||||
"""Test extracting value from a dictionary with a matching key pattern."""
|
||||
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_multiple_matches():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {
|
||||
"Bonjour",
|
||||
"Hello",
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_multiple_extensions():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_no_match():
|
||||
"""Test that empty list is returned when no key matches the pattern."""
|
||||
data = {"name": "Test", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert result == []
|
||||
|
||||
@@ -10,27 +10,6 @@ from bs4 import BeautifulSoup
|
||||
from core import enums
|
||||
|
||||
|
||||
def get_value_by_pattern(data, pattern):
|
||||
"""
|
||||
Get all values from keys matching a regex pattern in a dictionary.
|
||||
|
||||
Args:
|
||||
data (dict): Source dictionary to search
|
||||
pattern (str): Regex pattern to match against keys
|
||||
|
||||
Returns:
|
||||
list: List of values for all matching keys, empty list if no matches
|
||||
|
||||
Example:
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
|
||||
["Bonjour"]
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
|
||||
["Bonjour", "Hello"]
|
||||
"""
|
||||
regex = re.compile(pattern)
|
||||
return [value for key, value in data.items() if regex.match(key)]
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
@@ -112,8 +112,8 @@ class Base(Configuration):
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
INDEXING_URL = values.Value(
|
||||
default=None, environ_name="INDEXING_URL", environ_prefix=None
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
@@ -121,8 +121,8 @@ class Base(Configuration):
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_URL", environ_prefix=None
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
@@ -329,7 +329,6 @@ class Base(Configuration):
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
"csp.middleware.CSPMiddleware",
|
||||
"waffle.middleware.WaffleMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
@@ -351,7 +350,6 @@ class Base(Configuration):
|
||||
"parler",
|
||||
"treebeard",
|
||||
"easy_thumbnails",
|
||||
"waffle",
|
||||
# Django
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
|
||||
@@ -42,7 +42,6 @@ dependencies = [
|
||||
"django<6.0.0",
|
||||
"django-treebeard==4.8.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"django-waffle==5.0.0",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10.1",
|
||||
|
||||
@@ -232,6 +232,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
@@ -281,6 +282,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
@@ -329,6 +331,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 14,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
NEXT_PUBLIC_VAULT_URL=http://localhost:7201
|
||||
NEXT_PUBLIC_INTERFACE_URL=http://localhost:7202
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@sentry/nextjs": "10.34.0",
|
||||
"@tanstack/react-query": "5.90.18",
|
||||
"@tiptap/extensions": "*",
|
||||
"async-mutex": "^0.5.0",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -70,6 +71,7 @@
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.10"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import { UserEncryptionProvider } from '@/features/docs/doc-collaboration';
|
||||
import { VaultClientProvider } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
@@ -74,7 +76,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
<Auth>
|
||||
<VaultClientProvider>
|
||||
<UserEncryptionProvider>{children}</UserEncryptionProvider>
|
||||
</VaultClientProvider>
|
||||
</Auth>
|
||||
</ConfigProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* @interface User
|
||||
* @property {string} id - The id of the user.
|
||||
* @property {string} email - The email of the user.
|
||||
* @property {string} name - The name of the user.
|
||||
* @property {string} full_name - The full name of the user.
|
||||
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
suite_user_id: string | null;
|
||||
email: string;
|
||||
full_name: string;
|
||||
short_name: string;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogout } from '../utils';
|
||||
|
||||
import { ModalEncryptionOnboarding } from './ModalEncryptionOnboarding';
|
||||
import { ModalEncryptionSettings } from './ModalEncryptionSettings';
|
||||
|
||||
export const AccountMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { hasKeys } = useVaultClient();
|
||||
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
// hasKeys comes from the vault — true if the user has encryption keys on this device
|
||||
const hasEncryptionSetup = hasKeys === true;
|
||||
|
||||
const encryptionOption: DropdownMenuOption = useMemo(() => {
|
||||
if (hasEncryptionSetup) {
|
||||
return {
|
||||
label: t('Encryption settings'),
|
||||
icon: 'lock',
|
||||
callback: () => setIsSettingsOpen(true),
|
||||
showSeparator: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t('Enable encryption'),
|
||||
icon: 'lock_open',
|
||||
callback: () => setIsOnboardingOpen(true),
|
||||
showSeparator: true,
|
||||
};
|
||||
}, [hasEncryptionSetup, t]);
|
||||
|
||||
const options: DropdownMenuOption[] = useMemo(
|
||||
() => [
|
||||
encryptionOption,
|
||||
{
|
||||
label: t('Logout'),
|
||||
icon: 'logout',
|
||||
callback: () => gotoLogout(),
|
||||
},
|
||||
],
|
||||
[encryptionOption, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
showArrow
|
||||
label={t('My account')}
|
||||
buttonCss={css`
|
||||
transition: all var(--c--globals--transitions--duration)
|
||||
var(--c--globals--transitions--ease-out) !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
padding: 0.5rem 0.6rem;
|
||||
& > div {
|
||||
gap: 0.2rem;
|
||||
display: flex;
|
||||
}
|
||||
& .material-icons {
|
||||
color: var(
|
||||
--c--contextuals--content--palette--brand--primary
|
||||
) !important;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$theme="brand"
|
||||
$variation="tertiary"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$align="center"
|
||||
>
|
||||
<Icon iconName="person" $color="inherit" $size="xl" />
|
||||
{t('My account')}
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
{user && isOnboardingOpen && (
|
||||
<ModalEncryptionOnboarding
|
||||
isOpen
|
||||
onClose={() => setIsOnboardingOpen(false)}
|
||||
onSuccess={() => setIsOnboardingOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{user && isSettingsOpen && (
|
||||
<ModalEncryptionSettings
|
||||
isOpen
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
onRequestReOnboard={() => {
|
||||
setIsSettingsOpen(false);
|
||||
setIsOnboardingOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,16 @@ import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { BoxButton } from '@/components';
|
||||
|
||||
import ProConnectImg from '../assets/button-proconnect.svg';
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogin, gotoLogout } from '../utils';
|
||||
import { gotoLogin } from '../utils';
|
||||
import { AccountMenu } from './AccountMenu';
|
||||
|
||||
export const ButtonLogin = () => {
|
||||
const { t } = useTranslation();
|
||||
const { authenticated } = useAuth();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
@@ -28,26 +27,7 @@ export const ButtonLogin = () => {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
.--docs--button-logout:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
onClick={gotoLogout}
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
aria-label={t('Logout')}
|
||||
className="--docs--button-logout"
|
||||
>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
return <AccountMenu />;
|
||||
};
|
||||
|
||||
export const ProConnectButton = () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Encryption onboarding modal — delegates to the centralized encryption service.
|
||||
*
|
||||
* Opens the encryption service's interface iframe which handles everything:
|
||||
* key generation, backup, restore, device transfer, and server registration.
|
||||
* The product (Docs) doesn't manage public keys — it only stores fingerprints
|
||||
* on document accesses for UI purposes.
|
||||
*/
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useUserEncryption } from '@/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
interface ModalEncryptionOnboardingProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptionOnboarding = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ModalEncryptionOnboardingProps) => {
|
||||
const { client: vaultClient, refreshKeyState } = useVaultClient();
|
||||
const { refreshEncryption } = useUserEncryption();
|
||||
const onboardingOpenedRef = useRef(false);
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !vaultClient || !containerEl || onboardingOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onboardingOpenedRef.current = true;
|
||||
vaultClient.openOnboarding(containerEl);
|
||||
}, [isOpen, vaultClient, containerEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
|
||||
const handleComplete = async () => {
|
||||
// The encryption service registered the public key on its central server.
|
||||
// Docs doesn't need to store it — just refresh the vault key state.
|
||||
await refreshKeyState();
|
||||
refreshEncryption();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleClosed = () => {
|
||||
onboardingOpenedRef.current = false;
|
||||
onClose();
|
||||
};
|
||||
|
||||
vaultClient.on('onboarding:complete', handleComplete);
|
||||
vaultClient.on('interface:closed', handleClosed);
|
||||
|
||||
return () => {
|
||||
vaultClient.off('onboarding:complete', handleComplete);
|
||||
vaultClient.off('interface:closed', handleClosed);
|
||||
};
|
||||
}, [vaultClient, refreshKeyState, refreshEncryption, onSuccess, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
vaultClient?.closeInterface();
|
||||
onboardingOpenedRef.current = false;
|
||||
onClose();
|
||||
}, [vaultClient, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onboardingOpenedRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
closeOnClickOutside={false}
|
||||
onClose={handleClose}
|
||||
size={ModalSize.LARGE}
|
||||
hideCloseButton
|
||||
>
|
||||
<Box $minHeight="400px">
|
||||
<div
|
||||
ref={setContainerEl}
|
||||
style={{ width: '100%', minHeight: '400px' }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Encryption settings modal — delegates to the centralized encryption service.
|
||||
*
|
||||
* Opens the encryption service's settings interface iframe which handles:
|
||||
* fingerprint display, key deletion, device transfer export, and server key management.
|
||||
*/
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useUserEncryption } from '@/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
interface ModalEncryptionSettingsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRequestReOnboard: () => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptionSettings = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onRequestReOnboard,
|
||||
}: ModalEncryptionSettingsProps) => {
|
||||
const { client: vaultClient, refreshKeyState } = useVaultClient();
|
||||
const { refreshEncryption } = useUserEncryption();
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
const [settingsOpened, setSettingsOpened] = useState(false);
|
||||
|
||||
// Open the vault's settings interface when container is mounted
|
||||
useEffect(() => {
|
||||
if (!isOpen || !vaultClient || !containerEl || settingsOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingsOpened(true);
|
||||
vaultClient.openSettings(containerEl);
|
||||
}, [isOpen, vaultClient, containerEl, settingsOpened]);
|
||||
|
||||
// Listen for interface close and key changes
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
|
||||
const handleClosed = () => {
|
||||
setSettingsOpened(false);
|
||||
refreshKeyState().then(() => refreshEncryption());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeysDestroyed = () => {
|
||||
refreshKeyState().then(() => refreshEncryption());
|
||||
};
|
||||
|
||||
vaultClient.on('interface:closed', handleClosed);
|
||||
vaultClient.on('keys-destroyed', handleKeysDestroyed);
|
||||
|
||||
return () => {
|
||||
vaultClient.off('interface:closed', handleClosed);
|
||||
vaultClient.off('keys-destroyed', handleKeysDestroyed);
|
||||
};
|
||||
}, [vaultClient, refreshKeyState, refreshEncryption, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
vaultClient?.closeInterface();
|
||||
setSettingsOpened(false);
|
||||
onClose();
|
||||
}, [vaultClient, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSettingsOpened(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
closeOnClickOutside={false}
|
||||
onClose={handleClose}
|
||||
size={ModalSize.LARGE}
|
||||
hideCloseButton
|
||||
>
|
||||
<Box $minHeight="400px">
|
||||
<div
|
||||
ref={setContainerEl}
|
||||
style={{ width: '100%', minHeight: '400px' }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './AccountMenu';
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './ModalEncryptionOnboarding';
|
||||
export * from './ModalEncryptionSettings';
|
||||
export * from './UserAvatar';
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* User encryption context provider.
|
||||
*
|
||||
* MIGRATION NOTE: This provider now bridges between the VaultClient SDK
|
||||
* and the existing encryption context interface used by downstream components.
|
||||
* The vault handles all key storage and crypto operations — we no longer
|
||||
* expose raw CryptoKey objects. Instead, `encryptionSettings` signals that
|
||||
* encryption is available, and components use the VaultClient directly for
|
||||
* encrypt/decrypt operations.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import { useVaultClient } from './vault';
|
||||
|
||||
export type EncryptionError =
|
||||
| 'missing_private_key'
|
||||
| 'missing_public_key'
|
||||
| null;
|
||||
|
||||
interface UserEncryptionContextValue {
|
||||
encryptionLoading: boolean;
|
||||
/**
|
||||
* Non-null when the user has encryption keys available.
|
||||
* NOTE: userPrivateKey and userPublicKey are no longer raw CryptoKey objects.
|
||||
* They are kept as null placeholders for type compatibility. Use the VaultClient
|
||||
* directly for all crypto operations.
|
||||
*/
|
||||
encryptionSettings: {
|
||||
userId: string;
|
||||
userPrivateKey: null;
|
||||
userPublicKey: null;
|
||||
} | null;
|
||||
encryptionError: EncryptionError;
|
||||
refreshEncryption: () => void;
|
||||
}
|
||||
|
||||
const UserEncryptionContext = createContext<UserEncryptionContextValue>({
|
||||
encryptionLoading: true,
|
||||
encryptionSettings: null,
|
||||
encryptionError: null,
|
||||
refreshEncryption: () => {},
|
||||
});
|
||||
|
||||
export const UserEncryptionProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isReady, isLoading, hasKeys, error, refreshKeyState } =
|
||||
useVaultClient();
|
||||
const [, setRefreshTrigger] = useState(0);
|
||||
|
||||
const refreshEncryption = useCallback(() => {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
void refreshKeyState();
|
||||
}, [refreshKeyState]);
|
||||
|
||||
// Derive the legacy context value from the VaultClient state
|
||||
let encryptionSettings: UserEncryptionContextValue['encryptionSettings'] =
|
||||
null;
|
||||
let encryptionError: EncryptionError = null;
|
||||
|
||||
if (isReady && user?.suite_user_id) {
|
||||
if (hasKeys) {
|
||||
encryptionSettings = {
|
||||
userId: user.suite_user_id,
|
||||
userPrivateKey: null, // Keys are in the vault — use VaultClient for crypto
|
||||
userPublicKey: null,
|
||||
};
|
||||
} else {
|
||||
encryptionError = 'missing_private_key';
|
||||
}
|
||||
} else if (!isLoading && error) {
|
||||
encryptionError = 'missing_private_key';
|
||||
}
|
||||
|
||||
return (
|
||||
<UserEncryptionContext.Provider
|
||||
value={{
|
||||
encryptionLoading: isLoading,
|
||||
encryptionSettings,
|
||||
encryptionError,
|
||||
refreshEncryption,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserEncryptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUserEncryption = (): UserEncryptionContextValue =>
|
||||
useContext(UserEncryptionContext);
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Encrypted WebSocket wrapper for real-time Yjs collaboration.
|
||||
*
|
||||
* Uses the VaultClient SDK for all encrypt/decrypt operations via postMessage
|
||||
* to the vault iframe. All data transfers use ArrayBuffer for zero-copy
|
||||
* performance. The vault caches the decrypted symmetric key per session
|
||||
* so only the first message incurs the hybrid decapsulation cost.
|
||||
*/
|
||||
|
||||
|
||||
export class EncryptedWebSocket extends WebSocket {
|
||||
protected readonly vaultClient!: VaultClient;
|
||||
protected readonly encryptedSymmetricKey!: ArrayBuffer;
|
||||
protected readonly onSystemMessage?: (message: string) => void;
|
||||
|
||||
constructor(address: string | URL, protocols?: string | string[]) {
|
||||
super(address, protocols);
|
||||
|
||||
const originalAddEventListener = this.addEventListener.bind(this);
|
||||
|
||||
this.addEventListener = function <K extends keyof WebSocketEventMap>(
|
||||
type: K,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
const wrappedListener: typeof listener = async (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messageEvent = event as any;
|
||||
|
||||
// System messages (strings) bypass encryption
|
||||
if (typeof messageEvent.data === 'string') {
|
||||
this.onSystemMessage?.(messageEvent.data as string);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(messageEvent.data instanceof ArrayBuffer)) {
|
||||
throw new Error(
|
||||
'WebSocket data should always be ArrayBuffer (binaryType)',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt directly with ArrayBuffer — no base64 conversion
|
||||
const { data: decryptedBuffer } =
|
||||
await this.vaultClient.decryptWithKey(
|
||||
messageEvent.data as ArrayBuffer,
|
||||
this.encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const decryptedData = new Uint8Array(decryptedBuffer);
|
||||
|
||||
if (typeof listener === 'function') {
|
||||
listener.call(this, { ...event, data: decryptedData });
|
||||
} else {
|
||||
listener.handleEvent.call(this, {
|
||||
...event,
|
||||
data: decryptedData,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket decrypt error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
originalAddEventListener('message', wrappedListener, options);
|
||||
} else {
|
||||
originalAddEventListener(type, listener, options);
|
||||
}
|
||||
};
|
||||
|
||||
// Block direct onmessage assignment
|
||||
let explicitlySetListener: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((this: WebSocket, handlerEvent: MessageEvent) => any) | null;
|
||||
null;
|
||||
|
||||
Object.defineProperty(this, 'onmessage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return explicitlySetListener;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
||||
set(handler: ((handlerEvent: MessageEvent) => any) | null) {
|
||||
explicitlySetListener = null;
|
||||
|
||||
throw new Error(
|
||||
'"onmessage" should not be set directly. Use addEventListener instead. Run "yarn run patch-package"!',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sendSystemMessage(message: string) {
|
||||
super.send(message);
|
||||
}
|
||||
|
||||
send(message: Uint8Array<ArrayBuffer>) {
|
||||
// Encrypt directly with ArrayBuffer — no base64 conversion
|
||||
this.vaultClient
|
||||
.encryptWithKey(
|
||||
message.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey,
|
||||
)
|
||||
.then(({ encryptedData }) => {
|
||||
super.send(new Uint8Array(encryptedData));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('WebSocket encrypt error:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdaptedEncryptedWebsocketClass(options: {
|
||||
vaultClient: VaultClient;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
}) {
|
||||
return class extends EncryptedWebSocket {
|
||||
protected readonly vaultClient = options.vaultClient;
|
||||
protected readonly encryptedSymmetricKey = options.encryptedSymmetricKey;
|
||||
protected readonly onSystemMessage = options.onSystemMessage;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { userKeyPairAlgorithm } from './encryption';
|
||||
|
||||
export async function exportPrivateKeyAsJwk(
|
||||
privateKey: CryptoKey,
|
||||
): Promise<JsonWebKey> {
|
||||
return await crypto.subtle.exportKey('jwk', privateKey);
|
||||
}
|
||||
|
||||
export async function importPrivateKeyFromJwk(
|
||||
jwk: JsonWebKey,
|
||||
): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
|
||||
true,
|
||||
['decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function importPublicKeyFromJwk(
|
||||
jwk: JsonWebKey,
|
||||
): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportPublicKeyAsBase64(
|
||||
publicKey: CryptoKey,
|
||||
): Promise<string> {
|
||||
const rawPublicKey = await crypto.subtle.exportKey('spki', publicKey);
|
||||
|
||||
return Buffer.from(new Uint8Array(rawPublicKey)).toString('base64');
|
||||
}
|
||||
|
||||
// Derive a public JWK from a private JWK by removing private fields.
|
||||
export function derivePublicJwkFromPrivate(privateJwk: JsonWebKey): JsonWebKey {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { d, p, q, dp, dq, qi, ...publicJwk } = privateJwk;
|
||||
|
||||
return { ...publicJwk, key_ops: ['encrypt'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a JWK to a compact passphrase-like string.
|
||||
* This is a base64url encoding of the full JWK JSON - not a mnemonic,
|
||||
* but compact enough to be stored in a password manager.
|
||||
*/
|
||||
export function jwkToPassphrase(jwk: JsonWebKey): string {
|
||||
const json = JSON.stringify(jwk);
|
||||
const base64 = Buffer.from(json).toString('base64');
|
||||
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a passphrase string back to a JWK.
|
||||
*/
|
||||
export function passphraseToJwk(passphrase: string): JsonWebKey {
|
||||
const base64 = passphrase.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const json = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
|
||||
return JSON.parse(json) as JsonWebKey;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export const userKeyPairAlgorithm = 'RSA-OAEP';
|
||||
export const documentSymmetricKeyAlgorithm = 'AES-GCM';
|
||||
|
||||
export async function generateUserKeyPair(): Promise<CryptoKeyPair> {
|
||||
return await crypto.subtle.generateKey(
|
||||
{
|
||||
name: userKeyPairAlgorithm,
|
||||
modulusLength: 4096,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// generate a symmetric key for document encryption
|
||||
export async function generateSymmetricKey(): Promise<CryptoKey> {
|
||||
return await crypto.subtle.generateKey(
|
||||
{ name: documentSymmetricKeyAlgorithm, length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt a symmetric key with a user's public key
|
||||
export async function encryptSymmetricKey(
|
||||
symmetricKey: CryptoKey,
|
||||
publicKey: CryptoKey,
|
||||
): Promise<ArrayBuffer> {
|
||||
const raw = await crypto.subtle.exportKey('raw', symmetricKey);
|
||||
|
||||
// TODO:
|
||||
// TODO: should use something better than RSA-OAEP, but maybe WebCrypto is not enough (use downloaded library? "libsodium-wrappers" or so)
|
||||
// TODO:
|
||||
return await crypto.subtle.encrypt(
|
||||
{ name: userKeyPairAlgorithm },
|
||||
publicKey,
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
// decrypt a symmetric key with the local private key
|
||||
export async function decryptSymmetricKey(
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
privateKey: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
const raw = await crypto.subtle.decrypt(
|
||||
{ name: userKeyPairAlgorithm },
|
||||
privateKey,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
raw,
|
||||
{ name: documentSymmetricKeyAlgorithm },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// encrypt content with a symmetric key
|
||||
export async function encryptContent(
|
||||
content: Uint8Array<ArrayBuffer>,
|
||||
symmetricKey: CryptoKey,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: documentSymmetricKeyAlgorithm,
|
||||
iv,
|
||||
},
|
||||
symmetricKey,
|
||||
content,
|
||||
);
|
||||
|
||||
// Prepend IV to ciphertext so the recipient can extract it for decryption
|
||||
const result = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
result.set(iv);
|
||||
result.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// decrypt content with a symmetric key
|
||||
export async function decryptContent(
|
||||
encryptedContent: Uint8Array<ArrayBuffer>,
|
||||
symmetricKey: CryptoKey,
|
||||
): Promise<Uint8Array<ArrayBufferLike>> {
|
||||
const iv = encryptedContent.slice(0, 12);
|
||||
const ciphertext = encryptedContent.slice(12);
|
||||
const arrayBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: documentSymmetricKeyAlgorithm,
|
||||
iv,
|
||||
},
|
||||
symmetricKey,
|
||||
ciphertext,
|
||||
);
|
||||
|
||||
return new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a SHA-256 fingerprint of a base64-encoded public key.
|
||||
* Returns a formatted hex string like "A1B2 C3D4 E5F6 7890".
|
||||
*/
|
||||
export async function computeKeyFingerprint(
|
||||
base64Key: string,
|
||||
): Promise<string> {
|
||||
const raw = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
|
||||
const hash = await crypto.subtle.digest('SHA-256', raw);
|
||||
const hex = Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return hex
|
||||
.slice(0, 16)
|
||||
.replace(/(.{4})/g, '$1 ')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// prepare encrypted symmetric keys for all users with access to a document
|
||||
export async function prepareEncryptedSymmetricKeysForUsers(
|
||||
symmetricKey: CryptoKey,
|
||||
accessesPublicKeysPerUser: Record<string, ArrayBuffer>,
|
||||
): Promise<Record<string, ArrayBuffer>> {
|
||||
const result: Record<string, ArrayBuffer> = {};
|
||||
|
||||
// encrypt the symmetric key for each user's public key
|
||||
for (const [userId, publicKey] of Object.entries(accessesPublicKeysPerUser)) {
|
||||
const usablePublicKey = await crypto.subtle.importKey(
|
||||
'spki',
|
||||
publicKey,
|
||||
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
|
||||
result[userId] = await encryptSymmetricKey(symmetricKey, usablePublicKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
const DB_NAME = 'encryption';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
// Store names
|
||||
export const STORE_PRIVATE_KEY = 'privateKey';
|
||||
export const STORE_PUBLIC_KEY = 'publicKey';
|
||||
export const STORE_KNOWN_PUBLIC_KEYS = 'knownPublicKeys';
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||
|
||||
/**
|
||||
* Opens (or reuses) the encryption IndexedDB with all required object stores.
|
||||
* Uses a singleton promise so the upgrade callback only runs once.
|
||||
*/
|
||||
export function getEncryptionDB(): Promise<IDBPDatabase> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_PRIVATE_KEY)) {
|
||||
db.createObjectStore(STORE_PRIVATE_KEY);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_PUBLIC_KEY)) {
|
||||
db.createObjectStore(STORE_PUBLIC_KEY);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_KNOWN_PUBLIC_KEYS)) {
|
||||
db.createObjectStore(STORE_KNOWN_PUBLIC_KEYS);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Hook to manage document-level encryption state.
|
||||
*
|
||||
* Stores the user's encrypted symmetric key as an ArrayBuffer.
|
||||
* Components pass this to VaultClient.encryptWithKey() / decryptWithKey()
|
||||
* for all crypto operations. The vault decrypts the symmetric key internally
|
||||
* using the user's private key (with session caching for performance).
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useUserEncryption } from '../UserEncryptionProvider';
|
||||
|
||||
export type DocumentEncryptionError =
|
||||
| 'missing_symmetric_key'
|
||||
| 'decryption_failed'
|
||||
| null;
|
||||
|
||||
export interface DocumentEncryptionSettings {
|
||||
/**
|
||||
* The user's encrypted symmetric key as ArrayBuffer.
|
||||
* Pass this to VaultClient.encryptWithKey() / decryptWithKey().
|
||||
*/
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
}
|
||||
|
||||
/** Convert a base64 string to ArrayBuffer */
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function useDocumentEncryption(
|
||||
isDocumentEncrypted: boolean | undefined,
|
||||
userEncryptedSymmetricKeyBase64: string | undefined,
|
||||
): {
|
||||
documentEncryptionLoading: boolean;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
documentEncryptionError: DocumentEncryptionError;
|
||||
} {
|
||||
const { encryptionLoading, encryptionSettings } = useUserEncryption();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<DocumentEncryptionError>(null);
|
||||
|
||||
// Convert the base64 key from the API to ArrayBuffer (memoized)
|
||||
const encryptedSymmetricKey = useMemo(() => {
|
||||
if (!userEncryptedSymmetricKeyBase64) return null;
|
||||
|
||||
try {
|
||||
return base64ToArrayBuffer(userEncryptedSymmetricKeyBase64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [userEncryptedSymmetricKeyBase64]);
|
||||
|
||||
const settings = useMemo<DocumentEncryptionSettings | null>(() => {
|
||||
if (!encryptedSymmetricKey) return null;
|
||||
|
||||
return { encryptedSymmetricKey };
|
||||
}, [encryptedSymmetricKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionLoading && !encryptionSettings) {
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (encryptionLoading || isDocumentEncrypted === undefined) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDocumentEncrypted === false) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!encryptedSymmetricKey) {
|
||||
setError('missing_symmetric_key');
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}, [
|
||||
encryptionLoading,
|
||||
encryptionSettings,
|
||||
isDocumentEncrypted,
|
||||
encryptedSymmetricKey,
|
||||
]);
|
||||
|
||||
return {
|
||||
documentEncryptionLoading: loading,
|
||||
documentEncryptionSettings: error ? null : settings,
|
||||
documentEncryptionError: error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getEncryptionDB } from '../encryptionDB';
|
||||
|
||||
export type EncryptionError =
|
||||
| 'missing_private_key'
|
||||
| 'missing_public_key'
|
||||
| null;
|
||||
|
||||
export function useEncryption(
|
||||
userId?: string,
|
||||
refreshTrigger?: number,
|
||||
): {
|
||||
encryptionLoading: boolean;
|
||||
encryptionSettings: {
|
||||
userId: string;
|
||||
userPrivateKey: CryptoKey;
|
||||
userPublicKey: CryptoKey;
|
||||
} | null;
|
||||
encryptionError: EncryptionError;
|
||||
} {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<{
|
||||
userId: string;
|
||||
userPrivateKey: CryptoKey;
|
||||
userPublicKey: CryptoKey;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<EncryptionError>(null);
|
||||
|
||||
const enableEncryption: boolean = true; // TODO: this could be toggled for instances not needing encryption to save some requests
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function initEncryption() {
|
||||
// Waiting for asynchronous data before initializing encryption stuff
|
||||
if (!userId) {
|
||||
setLoading(true);
|
||||
setSettings(null);
|
||||
setError(null);
|
||||
return;
|
||||
} else if (enableEncryption === false) {
|
||||
setLoading(false);
|
||||
setSettings(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// We must first retrieve user keys locally
|
||||
const encryptionDatabase = await getEncryptionDB();
|
||||
|
||||
const userPrivateKey = await encryptionDatabase.get(
|
||||
'privateKey',
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
if (!userPrivateKey) {
|
||||
if (!cancelled) {
|
||||
setError('missing_private_key');
|
||||
setSettings(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const userPublicKey = await encryptionDatabase.get(
|
||||
'publicKey',
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
if (!userPublicKey) {
|
||||
if (!cancelled) {
|
||||
setError('missing_public_key');
|
||||
setSettings(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setSettings({
|
||||
userId: userId,
|
||||
userPrivateKey: userPrivateKey,
|
||||
userPublicKey: userPublicKey,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
if (!cancelled) {
|
||||
setSettings(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initEncryption();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, enableEncryption, refreshTrigger]);
|
||||
|
||||
return {
|
||||
encryptionLoading: loading,
|
||||
encryptionSettings: settings,
|
||||
encryptionError: error,
|
||||
};
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { computeKeyFingerprint } from '../encryption';
|
||||
|
||||
/**
|
||||
* Computes a SHA-256 fingerprint of a base64-encoded public key.
|
||||
* Returns a formatted hex string like "A1B2 C3D4 E5F6 7890", or null
|
||||
* if the key is not provided or still computing.
|
||||
*/
|
||||
export function useKeyFingerprint(
|
||||
base64Key: string | null | undefined,
|
||||
): string | null {
|
||||
const [fingerprint, setFingerprint] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!base64Key) {
|
||||
setFingerprint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
computeKeyFingerprint(base64Key).then((fp) => {
|
||||
if (!cancelled) {
|
||||
setFingerprint(fp);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [base64Key]);
|
||||
|
||||
return fingerprint;
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { STORE_KNOWN_PUBLIC_KEYS, getEncryptionDB } from '../encryptionDB';
|
||||
|
||||
export interface PublicKeyMismatch {
|
||||
userId: string;
|
||||
knownKey: string;
|
||||
currentKey: string;
|
||||
}
|
||||
|
||||
// module-level listener set to keep all hook instances in sync
|
||||
const registryListeners = new Set<() => void>();
|
||||
|
||||
function notifyRegistryUpdated() {
|
||||
registryListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/**
|
||||
* TOFU (Trust On First Use) public key registry.
|
||||
*
|
||||
* - On first encounter, a user's public key is stored locally in IndexedDB.
|
||||
* - On subsequent encounters, if the key differs from the stored one, it is
|
||||
* flagged as a mismatch.
|
||||
* - The caller can accept a new key via `acceptNewKey(userId)`, which updates
|
||||
* the locally stored key.
|
||||
*
|
||||
* All instances stay in sync via a module-level listener set.
|
||||
*/
|
||||
export function usePublicKeyRegistry(
|
||||
accessesPublicKeysPerUser: Record<string, string> | undefined,
|
||||
currentUserId?: string,
|
||||
) {
|
||||
const [mismatches, setMismatches] = useState<PublicKeyMismatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// listen for updates from other hook instances
|
||||
useEffect(() => {
|
||||
const handler = () => setRefreshTrigger((prev) => prev + 1);
|
||||
|
||||
registryListeners.add(handler);
|
||||
|
||||
return () => {
|
||||
registryListeners.delete(handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessesPublicKeysPerUser) {
|
||||
setMismatches([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function checkKeys() {
|
||||
try {
|
||||
const db = await getEncryptionDB();
|
||||
const newMismatches: PublicKeyMismatch[] = [];
|
||||
|
||||
for (const [userId, currentKey] of Object.entries(
|
||||
accessesPublicKeysPerUser!,
|
||||
)) {
|
||||
// Skip the current user — they know about their own key changes
|
||||
if (currentUserId && userId === currentUserId) {
|
||||
// Still store the key so it stays up to date locally
|
||||
await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const knownKey: string | undefined = await db.get(
|
||||
STORE_KNOWN_PUBLIC_KEYS,
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
if (!knownKey) {
|
||||
// First time seeing this user's key — trust on first use
|
||||
await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`);
|
||||
} else if (knownKey !== currentKey) {
|
||||
newMismatches.push({ userId, knownKey, currentKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setMismatches(newMismatches);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('usePublicKeyRegistry: failed to check keys', error);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
checkKeys();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessesPublicKeysPerUser, currentUserId, refreshTrigger]);
|
||||
|
||||
const acceptNewKey = useCallback(
|
||||
async (userId: string) => {
|
||||
const mismatch = mismatches.find((m) => m.userId === userId);
|
||||
if (!mismatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getEncryptionDB();
|
||||
await db.put(
|
||||
STORE_KNOWN_PUBLIC_KEYS,
|
||||
mismatch.currentKey,
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
setMismatches((prev) => prev.filter((m) => m.userId !== userId));
|
||||
|
||||
// notify other instances to re-check
|
||||
notifyRegistryUpdated();
|
||||
},
|
||||
[mismatches],
|
||||
);
|
||||
|
||||
return {
|
||||
mismatches,
|
||||
hasMismatches: mismatches.length > 0,
|
||||
loading,
|
||||
acceptNewKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export {
|
||||
computeKeyFingerprint,
|
||||
decryptContent,
|
||||
encryptContent,
|
||||
generateSymmetricKey,
|
||||
generateUserKeyPair,
|
||||
prepareEncryptedSymmetricKeysForUsers,
|
||||
encryptSymmetricKey,
|
||||
} from './encryption';
|
||||
export { getEncryptionDB } from './encryptionDB';
|
||||
export {
|
||||
useDocumentEncryption,
|
||||
type DocumentEncryptionError,
|
||||
} from './hook/useDocumentEncryption';
|
||||
export { useEncryption, type EncryptionError } from './hook/useEncryption';
|
||||
export {
|
||||
UserEncryptionProvider,
|
||||
useUserEncryption,
|
||||
} from './UserEncryptionProvider';
|
||||
export { useKeyFingerprint } from './hook/useKeyFingerprint';
|
||||
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
|
||||
export {
|
||||
exportPrivateKeyAsJwk,
|
||||
importPrivateKeyFromJwk,
|
||||
importPublicKeyFromJwk,
|
||||
exportPublicKeyAsBase64,
|
||||
derivePublicJwkFromPrivate,
|
||||
jwkToPassphrase,
|
||||
passphraseToJwk,
|
||||
} from './encryption-backup';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
|
||||
export class RelayProvider extends WebsocketProvider {
|
||||
// since the RelayProvider has been added to manage encryption that skips Hocuspocus logic
|
||||
// we mimic the needed properties for `SwitchableProvider` to be usable and to avoid use extra intermediaries
|
||||
get document() {
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
get configuration() {
|
||||
return {
|
||||
name: this.roomname,
|
||||
};
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* React context provider for the centralized encryption VaultClient SDK.
|
||||
*
|
||||
* The client SDK is loaded at runtime via a <script> tag from the vault domain
|
||||
* (data.encryption). Type declarations are provided by encryption-client.d.ts.
|
||||
*
|
||||
* This provider:
|
||||
* - Loads the client.js script from the vault URL
|
||||
* - Creates and initializes the VaultClient instance
|
||||
* - Sets auth context when the user logs in
|
||||
* - Tracks key state (hasKeys, publicKey)
|
||||
* - Provides the client to all downstream components
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
// Environment configuration
|
||||
const VAULT_URL =
|
||||
process.env.NEXT_PUBLIC_VAULT_URL ?? 'http://localhost:7201';
|
||||
const INTERFACE_URL =
|
||||
process.env.NEXT_PUBLIC_INTERFACE_URL ?? 'http://localhost:7202';
|
||||
|
||||
export interface VaultClientContextValue {
|
||||
/** The VaultClient instance, or null if not yet initialized */
|
||||
client: VaultClient | null;
|
||||
/** True once the vault iframe is ready AND auth context has been set */
|
||||
isReady: boolean;
|
||||
/** True while the vault is initializing */
|
||||
isLoading: boolean;
|
||||
/** Error message if initialization failed */
|
||||
error: string | null;
|
||||
/** Whether the current user has encryption keys on this device */
|
||||
hasKeys: boolean | null;
|
||||
/** The current user's public key, or null */
|
||||
publicKey: ArrayBuffer | null;
|
||||
/** Re-check key state (after onboarding, restore, etc.) */
|
||||
refreshKeyState: () => Promise<void>;
|
||||
}
|
||||
|
||||
const VaultClientContext = createContext<VaultClientContextValue>({
|
||||
client: null,
|
||||
isReady: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
hasKeys: null,
|
||||
publicKey: null,
|
||||
refreshKeyState: async () => {},
|
||||
});
|
||||
|
||||
/** Load the encryption client SDK script from the vault domain */
|
||||
function loadClientScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
if (window.EncryptionClient?.VaultClient) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script tag already exists
|
||||
const existing = document.querySelector(
|
||||
`script[src="${VAULT_URL}/client.js"]`,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load encryption client SDK')),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `${VAULT_URL}/client.js`;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () =>
|
||||
reject(new Error('Failed to load encryption client SDK'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function VaultClientProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, authenticated } = useAuth();
|
||||
const { i18n } = useTranslation();
|
||||
const { theme: cunninghamTheme } = useCunninghamTheme();
|
||||
const clientRef = useRef<VaultClient | null>(null);
|
||||
const [clientInitialized, setClientInitialized] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasKeys, setHasKeys] = useState<boolean | null>(null);
|
||||
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
// Load script + initialize VaultClient once
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
let destroyed = false;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await loadClientScript();
|
||||
|
||||
if (destroyed) return;
|
||||
|
||||
const client = new window.EncryptionClient.VaultClient({
|
||||
vaultUrl: VAULT_URL,
|
||||
interfaceUrl: INTERFACE_URL,
|
||||
theme: cunninghamTheme,
|
||||
lang: i18n.language,
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
|
||||
client.on('onboarding:complete', () => {
|
||||
setHasKeys(true);
|
||||
client
|
||||
.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
client.on('keys-changed', () => {
|
||||
client
|
||||
.hasKeys()
|
||||
.then(({ hasKeys: exists }) => {
|
||||
setHasKeys(exists);
|
||||
|
||||
if (exists) {
|
||||
client
|
||||
.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
client.on('keys-destroyed', () => {
|
||||
setHasKeys(false);
|
||||
setPublicKey(null);
|
||||
});
|
||||
|
||||
await client.init();
|
||||
|
||||
if (destroyed) {
|
||||
client.destroy();
|
||||
} else {
|
||||
setClientInitialized(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!destroyed) {
|
||||
setError((err as Error).message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
|
||||
if (clientRef.current) {
|
||||
clientRef.current.destroy();
|
||||
clientRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set auth context whenever user changes or client finishes initializing
|
||||
useEffect(() => {
|
||||
const client = clientRef.current;
|
||||
|
||||
if (
|
||||
!client ||
|
||||
!clientInitialized ||
|
||||
!authenticated ||
|
||||
!user?.id ||
|
||||
!user?.suite_user_id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function setupAuth() {
|
||||
if (cancelled || !client) return;
|
||||
|
||||
client.setAuthContext({
|
||||
suiteUserId: user!.suite_user_id!,
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hasKeys: exists } = await client.hasKeys();
|
||||
setHasKeys(exists);
|
||||
|
||||
if (exists) {
|
||||
const { publicKey: pk } = await client.getPublicKey();
|
||||
setPublicKey(pk);
|
||||
}
|
||||
|
||||
setIsReady(true);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void setupAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clientInitialized, authenticated, user?.id, user?.suite_user_id]);
|
||||
|
||||
const refreshKeyState = useCallback(async () => {
|
||||
const client = clientRef.current;
|
||||
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const { hasKeys: exists } = await client.hasKeys();
|
||||
setHasKeys(exists);
|
||||
|
||||
if (exists) {
|
||||
const { publicKey: pk } = await client.getPublicKey();
|
||||
setPublicKey(pk);
|
||||
} else {
|
||||
setPublicKey(null);
|
||||
}
|
||||
} catch {
|
||||
// Vault not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VaultClientContext.Provider
|
||||
value={{
|
||||
client: clientRef.current,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
hasKeys,
|
||||
publicKey,
|
||||
refreshKeyState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</VaultClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useVaultClient = (): VaultClientContextValue =>
|
||||
useContext(VaultClientContext);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Re-export types from encryption-client.d.ts for global availability
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface VaultClient {
|
||||
init(): Promise<void>;
|
||||
destroy(): void;
|
||||
setTheme(theme: string): void;
|
||||
setAuthContext(context: { suiteUserId: string }): void;
|
||||
hasKeys(): Promise<{ hasKeys: boolean }>;
|
||||
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>;
|
||||
encrypt(data: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
|
||||
decrypt(encryptedData: ArrayBuffer): Promise<{ data: ArrayBuffer }>;
|
||||
encryptForUsers(data: ArrayBuffer, userPublicKeys: Record<string, ArrayBuffer>): Promise<{ encryptedContent: ArrayBuffer; encryptedKeys: Record<string, ArrayBuffer> }>;
|
||||
encryptWithKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
|
||||
decryptWithKey(encryptedData: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer): Promise<{ data: ArrayBuffer }>;
|
||||
rewrapKey(encryptedSymmetricKey: ArrayBuffer, targetUserPublicKey: ArrayBuffer): Promise<{ rewrappedKey: ArrayBuffer }>;
|
||||
fetchPublicKeys(userIds: string[]): Promise<{ publicKeys: Record<string, ArrayBuffer> }>;
|
||||
checkFingerprints(userFingerprints: Record<string, string>, currentUserId?: string): Promise<{ results: Array<{ userId: string; knownFingerprint: string | null; providedFingerprint: string; status: 'trusted' | 'refused' | 'unknown' }> }>;
|
||||
acceptFingerprint(userId: string, fingerprint: string): Promise<void>;
|
||||
refuseFingerprint(userId: string, fingerprint: string): Promise<void>;
|
||||
getKnownFingerprints(): Promise<{ fingerprints: Record<string, { fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }> }>;
|
||||
openOnboarding(container: HTMLElement): void;
|
||||
openBackup(container: HTMLElement): void;
|
||||
openRestore(container: HTMLElement): void;
|
||||
openDeviceTransfer(container: HTMLElement): void;
|
||||
openSettings(container: HTMLElement): void;
|
||||
closeInterface(): void;
|
||||
on<K extends string>(event: K, listener: (data: any) => void): void;
|
||||
off<K extends string>(event: K, listener: (data: any) => void): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
EncryptionClient: {
|
||||
VaultClient: new (options: {
|
||||
vaultUrl: string;
|
||||
interfaceUrl: string;
|
||||
timeout?: number;
|
||||
theme?: string;
|
||||
lang?: string;
|
||||
}) => VaultClient;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VaultClientProvider, useVaultClient } from './VaultClientProvider';
|
||||
export type { VaultClientContextValue } from './VaultClientProvider';
|
||||
@@ -78,7 +78,7 @@ describe('DocEditor', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { rerender } = render(<DocEditor doc={doc} />, {
|
||||
const { rerender } = render(<DocEditor doc={doc} documentEncryptionSettings={null} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('DocEditor', () => {
|
||||
|
||||
// Rerender with same doc to check that event is not tracked again
|
||||
rerender(
|
||||
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} />,
|
||||
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} documentEncryptionSettings={null} />,
|
||||
);
|
||||
|
||||
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
|
||||
@@ -107,6 +107,7 @@ describe('DocEditor', () => {
|
||||
id: 'test-doc-id-2',
|
||||
computed_link_reach: LinkReach.RESTRICTED,
|
||||
}}
|
||||
documentEncryptionSettings={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
+68
-35
@@ -12,7 +12,6 @@ import * as locales from '@blocknote/core/locales';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -20,8 +19,13 @@ import type { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, useProviderStore } from '@/docs/doc-management';
|
||||
import {
|
||||
Doc,
|
||||
SwitchableProvider,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
|
||||
import {
|
||||
@@ -37,13 +41,17 @@ import { DocsBlockNoteEditor } from '../types';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { EncryptedDocBanner } from './EncryptedDocBanner';
|
||||
import { EncryptionProvider } from './EncryptionProvider';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { cssComments, useComments } from './comments/';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
AudioBlock,
|
||||
CalloutBlock,
|
||||
PdfBlock,
|
||||
UploadLoaderBlock,
|
||||
VideoBlock,
|
||||
} from './custom-blocks';
|
||||
import {
|
||||
InterlinkingLinkInlineContent,
|
||||
@@ -58,11 +66,13 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
audio: AudioBlock(),
|
||||
callout: CalloutBlock(),
|
||||
codeBlock: createCodeBlockSpec(codeBlockOptions),
|
||||
image: AccessibleImageBlock(),
|
||||
pdf: PdfBlock(),
|
||||
uploadLoader: UploadLoaderBlock(),
|
||||
video: VideoBlock(),
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
@@ -77,10 +87,15 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
provider: HocuspocusProvider;
|
||||
provider: SwitchableProvider;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
export const BlockNoteEditor = ({
|
||||
doc,
|
||||
provider,
|
||||
documentEncryptionSettings,
|
||||
}: BlockNoteEditorProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -91,14 +106,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
// Determine if comments should be visible in the UI
|
||||
const showComments = canSeeComment;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
|
||||
useSaveDoc(
|
||||
doc.id,
|
||||
provider.document,
|
||||
isConnectedToCollabServer,
|
||||
doc.is_encrypted,
|
||||
documentEncryptionSettings,
|
||||
);
|
||||
const { i18n } = useTranslation();
|
||||
let lang = i18n.resolvedLanguage;
|
||||
if (!lang || !(lang in locales)) {
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
|
||||
const encryptedSymmetricKey = documentEncryptionSettings?.encryptedSymmetricKey;
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id, encryptedSymmetricKey);
|
||||
|
||||
const collabName = user?.full_name || user?.email;
|
||||
const cursorName = collabName || t('Anonymous');
|
||||
@@ -200,7 +222,15 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[cursorName, lang, provider, uploadFile, threadStore, resolveUsers],
|
||||
[
|
||||
cursorName,
|
||||
lang,
|
||||
provider,
|
||||
uploadFile,
|
||||
encryptedSymmetricKey,
|
||||
threadStore,
|
||||
resolveUsers,
|
||||
],
|
||||
);
|
||||
|
||||
useHeadings(editor);
|
||||
@@ -218,35 +248,38 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
$textAlign="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
<EncryptionProvider encryptedSymmetricKey={encryptedSymmetricKey}>
|
||||
<EncryptedDocBanner />
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
$textAlign="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
</EncryptionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+22
-16
@@ -24,6 +24,7 @@ import {
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '../../types';
|
||||
import { useEncryption } from '../EncryptionProvider';
|
||||
|
||||
export const FileDownloadButton = ({
|
||||
open,
|
||||
@@ -32,6 +33,7 @@ export const FileDownloadButton = ({
|
||||
}) => {
|
||||
const dict = useDictionary();
|
||||
const Components = useComponentsContext();
|
||||
const { isEncrypted, decryptFileUrl } = useEncryption();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
@@ -75,30 +77,34 @@ export const FileDownloadButton = ({
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
* we do what Blocknote was doing initially.
|
||||
*
|
||||
* For this case, no need of adding decryption logic
|
||||
*/
|
||||
if (!url.includes(window.location.hostname) && !url.includes('base64')) {
|
||||
if (!editor.resolveFileUrl) {
|
||||
if (!isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
void editor
|
||||
.resolveFileUrl(url)
|
||||
.then((downloadUrl) => window.open(downloadUrl));
|
||||
if (!isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAndDownload = async (fileName: string) => {
|
||||
if (isEncrypted) {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
const blob = await fetch(blobUrl).then((r) => r.blob());
|
||||
downloadFile(blob, fileName);
|
||||
} else {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, fileName);
|
||||
}
|
||||
};
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, name || url.split('/').pop() || 'file');
|
||||
await fetchAndDownload(name || url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
|
||||
const baseName = name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
@@ -106,13 +112,13 @@ export const FileDownloadButton = ({
|
||||
? baseName.replace(regFindLastDot, '-unsafe$1')
|
||||
: baseName + '-unsafe';
|
||||
|
||||
downloadFile(blob, unsafeName);
|
||||
await fetchAndDownload(unsafeName);
|
||||
};
|
||||
|
||||
open(onConfirm);
|
||||
}
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
}, [editor, fileBlock, open, isEncrypted, decryptFileUrl]);
|
||||
|
||||
if (!fileBlock || fileBlock.props.url === '' || !Components) {
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Loading } from '@/components';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
@@ -76,9 +77,13 @@ export const DocEditorContainer = ({
|
||||
|
||||
interface DocEditorProps {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
export const DocEditor = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
}: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
@@ -130,7 +135,12 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
<>
|
||||
{isDesktop && <TableContent />}
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
docHeader={
|
||||
<DocHeader
|
||||
doc={doc}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
/>
|
||||
}
|
||||
docEditor={
|
||||
readOnly ? (
|
||||
<BlockNoteReader
|
||||
@@ -140,7 +150,11 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
docId={doc.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
<BlockNoteEditor
|
||||
doc={doc}
|
||||
provider={provider}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
|
||||
import { useEncryption } from './EncryptionProvider';
|
||||
|
||||
export const EncryptedDocBanner = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isEncrypted, pendingPlaceholders, requestRevealAll } =
|
||||
useEncryption();
|
||||
|
||||
if (!isEncrypted || pendingPlaceholders === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="flex-end"
|
||||
$padding={{ horizontal: '54px', vertical: '3xs' }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={requestRevealAll}
|
||||
icon={<Icon iconName="visibility" $size="sm" $color="inherit" />}
|
||||
>
|
||||
{t('Reveal all media')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Loading } from '@/components';
|
||||
|
||||
interface EncryptedMediaPlaceholderProps {
|
||||
label: string;
|
||||
errorLabel: string;
|
||||
minHeight?: string;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
onDecrypt: () => void;
|
||||
}
|
||||
|
||||
export const EncryptedMediaPlaceholder = ({
|
||||
label,
|
||||
errorLabel,
|
||||
minHeight = '200px',
|
||||
isLoading,
|
||||
hasError,
|
||||
onDecrypt,
|
||||
}: EncryptedMediaPlaceholderProps) => {
|
||||
if (hasError) {
|
||||
return (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$color="#666"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$minHeight={minHeight}
|
||||
$padding="20px"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
`}
|
||||
contentEditable={false}
|
||||
>
|
||||
{errorLabel}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$color="#666"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$minHeight={minHeight}
|
||||
$padding="20px"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
cursor: ${isLoading ? 'wait' : 'pointer'};
|
||||
position: relative;
|
||||
`}
|
||||
contentEditable={false}
|
||||
onClick={() => !isLoading && onDecrypt()}
|
||||
>
|
||||
<Icon iconName="lock" $size="24px" />
|
||||
<Box $margin={{ top: '2px' }}>{label}</Box>
|
||||
{isLoading && (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$css={css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(245, 245, 245, 0.8);
|
||||
`}
|
||||
>
|
||||
<Loading />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Encryption context for document content and file attachments.
|
||||
* Uses VaultClient SDK with ArrayBuffer for all decrypt operations.
|
||||
*/
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
aac: 'audio/aac',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
interface EncryptionContextValue {
|
||||
isEncrypted: boolean;
|
||||
decryptFileUrl: (url: string) => Promise<string>;
|
||||
revealAllCounter: number;
|
||||
requestRevealAll: () => void;
|
||||
pendingPlaceholders: number;
|
||||
registerPlaceholder: () => void;
|
||||
unregisterPlaceholder: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const DEFAULT_VALUE: EncryptionContextValue = {
|
||||
isEncrypted: false,
|
||||
decryptFileUrl: async (url: string) => url,
|
||||
revealAllCounter: 0,
|
||||
requestRevealAll: noop,
|
||||
pendingPlaceholders: 0,
|
||||
registerPlaceholder: noop,
|
||||
unregisterPlaceholder: noop,
|
||||
};
|
||||
|
||||
const EncryptionContext = createContext<EncryptionContextValue>(DEFAULT_VALUE);
|
||||
|
||||
interface EncryptionProviderProps {
|
||||
encryptedSymmetricKey: ArrayBuffer | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const EncryptionProvider = ({
|
||||
encryptedSymmetricKey,
|
||||
children,
|
||||
}: EncryptionProviderProps) => {
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const blobUrlCacheRef = useRef<Map<string, string>>(new Map());
|
||||
const [revealAllCounter, setRevealAllCounter] = useState(0);
|
||||
const [pendingPlaceholders, setPendingPlaceholders] = useState(0);
|
||||
|
||||
const requestRevealAll = useCallback(() => {
|
||||
setRevealAllCounter((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const registerPlaceholder = useCallback(() => {
|
||||
setPendingPlaceholders((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const unregisterPlaceholder = useCallback(() => {
|
||||
setPendingPlaceholders((c) => Math.max(0, c - 1));
|
||||
}, []);
|
||||
|
||||
const decryptFileUrl = useCallback(
|
||||
async (url: string): Promise<string> => {
|
||||
if (!encryptedSymmetricKey || !vaultClient) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const cached = blobUrlCacheRef.current.get(url);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch encrypted attachment: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get file as ArrayBuffer directly — no base64 conversion
|
||||
const encryptedBuffer = await response.arrayBuffer();
|
||||
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
encryptedBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const ext = url.split('.').pop()?.toLowerCase() || '';
|
||||
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
||||
|
||||
const blob = new Blob([decryptedBuffer], { type: mime });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCacheRef.current.set(url, blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
},
|
||||
[encryptedSymmetricKey, vaultClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobUrlCacheRef.current.forEach((blobUrl) =>
|
||||
URL.revokeObjectURL(blobUrl),
|
||||
);
|
||||
blobUrlCacheRef.current.clear();
|
||||
};
|
||||
}, [encryptedSymmetricKey]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isEncrypted: !!encryptedSymmetricKey,
|
||||
decryptFileUrl,
|
||||
revealAllCounter,
|
||||
requestRevealAll,
|
||||
pendingPlaceholders,
|
||||
registerPlaceholder,
|
||||
unregisterPlaceholder,
|
||||
}),
|
||||
[
|
||||
encryptedSymmetricKey,
|
||||
decryptFileUrl,
|
||||
revealAllCounter,
|
||||
requestRevealAll,
|
||||
pendingPlaceholders,
|
||||
registerPlaceholder,
|
||||
unregisterPlaceholder,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<EncryptionContext.Provider value={value}>
|
||||
{children}
|
||||
</EncryptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useEncryption = (): EncryptionContextValue =>
|
||||
useContext(EncryptionContext);
|
||||
+252
-88
@@ -1,130 +1,294 @@
|
||||
/**
|
||||
* AccessibleImageBlock.tsx
|
||||
*
|
||||
* This file defines a custom BlockNote block specification for an accessible image block.
|
||||
* It extends the default image block to ensure compliance with accessibility standards,
|
||||
* specifically RGAA 1.9.1, by using <figure> and <figcaption> elements when a caption is provided.
|
||||
* Custom BlockNote block for accessible images with encryption support.
|
||||
*
|
||||
* The accessible image block ensures that:
|
||||
* Accessibility (RGAA 1.9.1):
|
||||
* - Images with captions are wrapped in <figure> and <figcaption> elements.
|
||||
* - The <img> element has an appropriate alt attribute based on the caption.
|
||||
* - Accessibility attributes such as role and aria-label are added for better screen reader support.
|
||||
* - Images without captions have alt="" and are marked as decorative with aria-hidden="true".
|
||||
*
|
||||
* This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility.
|
||||
* Encryption:
|
||||
* - Images < 2MB are auto-decrypted inline.
|
||||
* - Images >= 2MB show a "click to decrypt" placeholder.
|
||||
*
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockFromConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
ImageOptions,
|
||||
InlineContentSchema,
|
||||
InlineContentSchemaFromSpecs,
|
||||
StyleSchema,
|
||||
createBlockSpec,
|
||||
createImageBlockConfig,
|
||||
defaultInlineContentSpecs,
|
||||
imageParse,
|
||||
imageRender,
|
||||
imageToExternalHTML,
|
||||
} from '@blocknote/core';
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type CreateImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
import { Icon, Loading } from '@/components';
|
||||
|
||||
export const accessibleImageRender =
|
||||
(config: ImageOptions) =>
|
||||
(
|
||||
block: BlockFromConfig<
|
||||
CreateImageBlockConfig,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', CreateImageBlockConfig>,
|
||||
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(config);
|
||||
const dom = imageRenderComputed(block, editor).dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
import { ANALYZE_URL } from '../../conf';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
import { useEncryption } from '../EncryptionProvider';
|
||||
|
||||
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
type ImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
|
||||
const figureElement = document.createElement('figure');
|
||||
const AUTOMATIC_DECRYPTION_MAX_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
// Copy all attributes from the original div
|
||||
figureElement.className = dom.className;
|
||||
const styleAttr = dom.getAttribute('style');
|
||||
if (styleAttr) {
|
||||
figureElement.setAttribute('style', styleAttr);
|
||||
}
|
||||
figureElement.style.setProperty('margin', '0');
|
||||
interface AccessibleImageProps {
|
||||
src: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
Array.from(dom.children).forEach((child) => {
|
||||
figureElement.appendChild(child.cloneNode(true));
|
||||
const AccessibleImage = ({ src, caption }: AccessibleImageProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (caption) {
|
||||
return (
|
||||
<figure
|
||||
style={{ margin: 0 }}
|
||||
role="img"
|
||||
aria-label={t('Image: {{title}}', { title: caption })}
|
||||
>
|
||||
<img
|
||||
className="bn-visual-media"
|
||||
src={src}
|
||||
alt={caption}
|
||||
tabIndex={0}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
<figcaption className="bn-file-caption">{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="bn-visual-media"
|
||||
src={src}
|
||||
alt=""
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImageBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'image', ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const ImageBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
...rest
|
||||
}: ImageBlockComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isEncrypted, decryptFileUrl } = useEncryption();
|
||||
|
||||
const url = block.props.url;
|
||||
const caption = block.props.caption || '';
|
||||
const isAnalyzing = !!url && url.includes(ANALYZE_URL);
|
||||
|
||||
// Encrypted state
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [showClickPlaceholder, setShowClickPlaceholder] = useState(false);
|
||||
|
||||
// Auto-decrypt small files, show placeholder for large ones
|
||||
useEffect(() => {
|
||||
if (!isEncrypted || !url || isAnalyzing) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
fetch(url, { method: 'HEAD', credentials: 'include' })
|
||||
.then(async (headResponse) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentLength = Number(
|
||||
headResponse.headers.get('content-length'),
|
||||
);
|
||||
|
||||
// Larger images show a "click to decrypt" placeholder instead to save decryption processing
|
||||
// (needed since photos taken from a smartphone can easily be over 15MB)
|
||||
if (contentLength < AUTOMATIC_DECRYPTION_MAX_SIZE) {
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
|
||||
if (!cancelled) {
|
||||
setResolvedUrl(blobUrl);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setShowClickPlaceholder(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) {
|
||||
setShowClickPlaceholder(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setShowClickPlaceholder(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Replace the <p> caption with <figcaption>
|
||||
const figcaptionElement = document.createElement('figcaption');
|
||||
const originalCaption = figureElement.querySelector('.bn-file-caption');
|
||||
if (originalCaption) {
|
||||
figcaptionElement.className = originalCaption.className;
|
||||
figcaptionElement.textContent = originalCaption.textContent;
|
||||
originalCaption.parentNode?.replaceChild(
|
||||
figcaptionElement,
|
||||
originalCaption,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isEncrypted, url, isAnalyzing, decryptFileUrl]);
|
||||
|
||||
// Add explicit role and aria-label for better screen reader support
|
||||
figureElement.setAttribute('role', 'img');
|
||||
figureElement.setAttribute(
|
||||
'aria-label',
|
||||
t(`Image: {{title}}`, { title: figcaptionElement.textContent }),
|
||||
);
|
||||
const handleDecrypt = useCallback(async () => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
setResolvedUrl(blobUrl);
|
||||
setShowClickPlaceholder(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [url, decryptFileUrl]);
|
||||
|
||||
// Remove the duplicate <p class="bn-file-caption"> added by ResizableFileBlockWrapper
|
||||
// when we render our own <figcaption> inside a <figure>.
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!wrapperRef.current || !caption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = wrapperRef.current.closest(
|
||||
'.bn-file-block-content-wrapper',
|
||||
);
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pCaption = wrapper.querySelector(':scope > p.bn-file-caption');
|
||||
if (pCaption) {
|
||||
pCaption.remove();
|
||||
}
|
||||
}, [caption]);
|
||||
|
||||
const effectiveUrl = isEncrypted ? resolvedUrl : url;
|
||||
const showMedia = !!effectiveUrl && !isAnalyzing;
|
||||
const showEncryptedPlaceholder =
|
||||
isEncrypted && (showClickPlaceholder || hasError) && !resolvedUrl;
|
||||
|
||||
return (
|
||||
<ResizableFileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
buttonIcon={
|
||||
<Icon iconName="image" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
>
|
||||
{isEncrypted && isLoading && !resolvedUrl && !showClickPlaceholder && (
|
||||
<Loading />
|
||||
)}
|
||||
{showEncryptedPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and view image')}
|
||||
errorLabel={t('Failed to decrypt image.')}
|
||||
isLoading={isLoading}
|
||||
hasError={hasError}
|
||||
onDecrypt={() => void handleDecrypt()}
|
||||
/>
|
||||
)}
|
||||
{showMedia && (
|
||||
<span ref={wrapperRef}>
|
||||
<AccessibleImage src={effectiveUrl} caption={caption} />
|
||||
</span>
|
||||
)}
|
||||
</ResizableFileBlockWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
// Return the figure element as the new dom
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
};
|
||||
};
|
||||
const ImageToExternalHTML = ({
|
||||
block,
|
||||
}: {
|
||||
block: BlockNoDefaults<
|
||||
Record<'image', ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}) => {
|
||||
if (!block.props.url) {
|
||||
return <p>Add image</p>;
|
||||
}
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
const img = (
|
||||
<img
|
||||
src={block.props.url}
|
||||
alt={block.props.caption || ''}
|
||||
width={block.props.previewWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
if (block.props.caption) {
|
||||
return (
|
||||
<figure role="img" aria-label={block.props.caption}>
|
||||
{img}
|
||||
<figcaption>{block.props.caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
return img;
|
||||
};
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
return withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
};
|
||||
|
||||
export const AccessibleImageBlock = createBlockSpec(
|
||||
export const AccessibleImageBlock = createReactBlockSpec(
|
||||
createImageBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['image/*'],
|
||||
},
|
||||
render: accessibleImageRender(config),
|
||||
render: (props) => <ImageBlockComponent {...(props as any)} />,
|
||||
parse: imageParse(config),
|
||||
toExternalHTML: imageToExternalHTML(config),
|
||||
toExternalHTML: (props) => <ImageToExternalHTML {...(props as any)} />,
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
audioParse,
|
||||
createAudioBlockConfig,
|
||||
} from '@blocknote/core';
|
||||
import { FileBlockWrapper, createReactBlockSpec } from '@blocknote/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
|
||||
import { useDecryptMedia } from '../../hook';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
|
||||
type AudioBlockConfig = ReturnType<typeof createAudioBlockConfig>;
|
||||
|
||||
interface AudioBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'audio', AudioBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'audio', AudioBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const AudioBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
...rest
|
||||
}: AudioBlockComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showPlaceholder,
|
||||
showMedia,
|
||||
isLoading,
|
||||
hasError,
|
||||
decrypt,
|
||||
resolvedUrl,
|
||||
} = useDecryptMedia(block.props.url);
|
||||
|
||||
return (
|
||||
<FileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
buttonIcon={
|
||||
<Icon iconName="audiotrack" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
>
|
||||
{showPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and play audio')}
|
||||
errorLabel={t('Failed to decrypt audio file.')}
|
||||
minHeight="80px"
|
||||
isLoading={isLoading}
|
||||
hasError={hasError}
|
||||
onDecrypt={() => void decrypt()}
|
||||
/>
|
||||
)}
|
||||
{showMedia && (
|
||||
<audio
|
||||
className="bn-audio"
|
||||
src={resolvedUrl || block.props.url}
|
||||
controls
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</FileBlockWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioToExternalHTML = ({
|
||||
block,
|
||||
}: {
|
||||
block: BlockNoDefaults<
|
||||
Record<'audio', AudioBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}) => {
|
||||
if (!block.props.url) {
|
||||
return <p>Add audio</p>;
|
||||
}
|
||||
|
||||
return <audio src={block.props.url} controls />;
|
||||
};
|
||||
|
||||
export const AudioBlock = createReactBlockSpec(
|
||||
createAudioBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['audio/*'],
|
||||
},
|
||||
render: (props) => <AudioBlockComponent {...(props as any)} />,
|
||||
parse: audioParse(config),
|
||||
toExternalHTML: (props) => <AudioToExternalHTML {...(props as any)} />,
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
+46
-6
@@ -13,7 +13,7 @@ import {
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Box, Icon, Loading } from '@/components';
|
||||
|
||||
import { ANALYZE_URL } from '../../conf';
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
import { useEncryption } from '../EncryptionProvider';
|
||||
|
||||
const PDFBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
|
||||
@@ -67,9 +69,12 @@ const PdfBlockComponent = ({
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
const { isEncrypted, decryptFileUrl } = useEncryption();
|
||||
|
||||
const [isPDFContent, setIsPDFContent] = useState<boolean | null>(null);
|
||||
const [isPDFContentLoading, setIsPDFContentLoading] =
|
||||
useState<boolean>(false);
|
||||
const [resolvedPdfUrl, setResolvedPdfUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && locales[lang as keyof typeof locales]) {
|
||||
@@ -86,8 +91,9 @@ const PdfBlockComponent = ({
|
||||
}
|
||||
}, [lang, t]);
|
||||
|
||||
// For non-encrypted docs, validate PDF content on mount
|
||||
useEffect(() => {
|
||||
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
|
||||
if (isEncrypted || !pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +107,7 @@ const PdfBlockComponent = ({
|
||||
|
||||
if (response.ok && contentType?.includes('application/pdf')) {
|
||||
setIsPDFContent(true);
|
||||
setResolvedPdfUrl(pdfUrl);
|
||||
} else {
|
||||
setIsPDFContent(false);
|
||||
}
|
||||
@@ -112,12 +119,45 @@ const PdfBlockComponent = ({
|
||||
};
|
||||
|
||||
void validatePDFContent();
|
||||
}, [pdfUrl]);
|
||||
}, [pdfUrl, isEncrypted]);
|
||||
|
||||
const handleDecryptPdf = useCallback(async () => {
|
||||
if (!pdfUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPDFContentLoading(true);
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(pdfUrl);
|
||||
setResolvedPdfUrl(blobUrl);
|
||||
setIsPDFContent(true);
|
||||
} catch {
|
||||
setIsPDFContent(false);
|
||||
} finally {
|
||||
setIsPDFContentLoading(false);
|
||||
}
|
||||
}, [pdfUrl, decryptFileUrl]);
|
||||
|
||||
const showEncryptedPlaceholder =
|
||||
isEncrypted &&
|
||||
isPDFContent === null &&
|
||||
pdfUrl &&
|
||||
!pdfUrl.includes(ANALYZE_URL);
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
{isPDFContentLoading && <Loading />}
|
||||
{!isEncrypted && isPDFContentLoading && <Loading />}
|
||||
{showEncryptedPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and view PDF')}
|
||||
errorLabel={t('Invalid or missing PDF file.')}
|
||||
minHeight="300px"
|
||||
isLoading={isPDFContentLoading}
|
||||
hasError={false}
|
||||
onDecrypt={() => void handleDecryptPdf()}
|
||||
/>
|
||||
)}
|
||||
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
|
||||
<Box
|
||||
$align="center"
|
||||
@@ -142,7 +182,7 @@ const PdfBlockComponent = ({
|
||||
block={block as unknown as FileBlockBlock}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
>
|
||||
{!isPDFContentLoading && isPDFContent && (
|
||||
{!isPDFContentLoading && isPDFContent && resolvedPdfUrl && (
|
||||
<Box
|
||||
as="embed"
|
||||
className="bn-visual-media"
|
||||
@@ -150,7 +190,7 @@ const PdfBlockComponent = ({
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
src={resolvedPdfUrl}
|
||||
aria-label={block.props.name || t('PDF document')}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
createVideoBlockConfig,
|
||||
videoParse,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
|
||||
import { useDecryptMedia } from '../../hook';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
|
||||
type VideoBlockConfig = ReturnType<typeof createVideoBlockConfig>;
|
||||
|
||||
interface VideoBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'video', VideoBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'video', VideoBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const VideoBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
...rest
|
||||
}: VideoBlockComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showPlaceholder,
|
||||
showMedia,
|
||||
isLoading,
|
||||
hasError,
|
||||
decrypt,
|
||||
resolvedUrl,
|
||||
} = useDecryptMedia(block.props.url);
|
||||
|
||||
return (
|
||||
<ResizableFileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
buttonIcon={
|
||||
<Icon iconName="videocam" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
>
|
||||
{showPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and play video')}
|
||||
errorLabel={t('Failed to decrypt video file.')}
|
||||
minHeight="300px"
|
||||
isLoading={isLoading}
|
||||
hasError={hasError}
|
||||
onDecrypt={() => void decrypt()}
|
||||
/>
|
||||
)}
|
||||
{showMedia && (
|
||||
<video
|
||||
className="bn-visual-media"
|
||||
src={resolvedUrl || block.props.url}
|
||||
controls
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</ResizableFileBlockWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoToExternalHTML = ({
|
||||
block,
|
||||
}: {
|
||||
block: BlockNoDefaults<
|
||||
Record<'video', VideoBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}) => {
|
||||
if (!block.props.url) {
|
||||
return <p>Add video</p>;
|
||||
}
|
||||
|
||||
return <video src={block.props.url} controls />;
|
||||
};
|
||||
|
||||
export const VideoBlock = createReactBlockSpec(
|
||||
createVideoBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['video/*'],
|
||||
},
|
||||
render: (props) => <VideoBlockComponent {...(props as any)} />,
|
||||
parse: videoParse(config),
|
||||
toExternalHTML: (props) => <VideoToExternalHTML {...(props as any)} />,
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './AudioBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
export * from './VideoBlock';
|
||||
|
||||
+5
-8
@@ -3,7 +3,6 @@ import {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -27,14 +26,12 @@ import {
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
Doc,
|
||||
getEmojiAndTitle,
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
import { DocSearchContent } from '@/docs/doc-search/components/DocSearchContent';
|
||||
import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const inputStyle = css`
|
||||
@@ -90,7 +87,7 @@ export const SearchPage = ({
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isEditable = editor.isEditable;
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
/**
|
||||
* createReactInlineContentSpec add automatically the focus after
|
||||
* the inline content, so we need to set the focus on the input
|
||||
@@ -228,14 +225,14 @@ export const SearchPage = ({
|
||||
`}
|
||||
$margin={{ top: '0.5rem' }}
|
||||
>
|
||||
<DocSearchContent
|
||||
<DocSearchSubPageContent
|
||||
search={search}
|
||||
target={DocSearchTarget.CURRENT}
|
||||
parentPath={treeContext?.root?.path}
|
||||
filters={{ target: DocSearchTarget.CURRENT }}
|
||||
onSelect={(doc) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
|
||||
+4
-4
@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('useSaveDoc', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('useSaveDoc', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('useSaveDoc', () => {
|
||||
const docId = 'test-doc-id';
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useDecryptMedia';
|
||||
export * from './useHeadings';
|
||||
export * from './useSaveDoc';
|
||||
export * from './useShortcuts';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEncryption } from '../components/EncryptionProvider';
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
|
||||
export const useDecryptMedia = (url: string | undefined) => {
|
||||
const {
|
||||
isEncrypted,
|
||||
decryptFileUrl,
|
||||
revealAllCounter,
|
||||
registerPlaceholder,
|
||||
unregisterPlaceholder,
|
||||
} = useEncryption();
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const isAnalyzing = !!url && url.includes(ANALYZE_URL);
|
||||
|
||||
const decrypt = useCallback(async () => {
|
||||
if (!url || resolvedUrl || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
setResolvedUrl(blobUrl);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [url, resolvedUrl, isLoading, decryptFileUrl]);
|
||||
|
||||
// Auto-decrypt when "Reveal all" is requested
|
||||
const decryptRef = useRef(decrypt);
|
||||
decryptRef.current = decrypt;
|
||||
|
||||
useEffect(() => {
|
||||
if (revealAllCounter > 0 && isEncrypted && url && !isAnalyzing) {
|
||||
void decryptRef.current();
|
||||
}
|
||||
}, [revealAllCounter, isEncrypted, url, isAnalyzing]);
|
||||
|
||||
const showPlaceholder =
|
||||
isEncrypted && !resolvedUrl && !hasError && !!url && !isAnalyzing;
|
||||
|
||||
const showMedia = !!url && !isAnalyzing && (!isEncrypted || !!resolvedUrl);
|
||||
|
||||
// Track pending placeholders in the provider
|
||||
const wasShowingPlaceholder = useRef(false);
|
||||
useEffect(() => {
|
||||
if (showPlaceholder && !wasShowingPlaceholder.current) {
|
||||
registerPlaceholder();
|
||||
wasShowingPlaceholder.current = true;
|
||||
} else if (!showPlaceholder && wasShowingPlaceholder.current) {
|
||||
unregisterPlaceholder();
|
||||
wasShowingPlaceholder.current = false;
|
||||
}
|
||||
}, [showPlaceholder, registerPlaceholder, unregisterPlaceholder]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wasShowingPlaceholder.current) {
|
||||
unregisterPlaceholder();
|
||||
}
|
||||
};
|
||||
}, [unregisterPlaceholder]);
|
||||
|
||||
return {
|
||||
isEncrypted,
|
||||
resolvedUrl,
|
||||
isLoading,
|
||||
hasError,
|
||||
decrypt,
|
||||
showPlaceholder,
|
||||
showMedia,
|
||||
};
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useUpdateDoc, useProviderStore } from '@/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
@@ -14,7 +16,11 @@ export const useSaveDoc = (
|
||||
docId: string,
|
||||
yDoc: Y.Doc,
|
||||
isConnectedToCollabServer: boolean,
|
||||
isEncrypted: boolean,
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null,
|
||||
) => {
|
||||
const { encryptionTransition } = useProviderStore();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
onSuccess: () => {
|
||||
@@ -23,11 +29,6 @@ export const useSaveDoc = (
|
||||
});
|
||||
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Update initial doc when doc is updated by other users,
|
||||
* so only the user typing will trigger the save.
|
||||
* This is to avoid saving the same doc multiple time.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onUpdate = (
|
||||
_uintArray: Uint8Array,
|
||||
@@ -48,16 +49,53 @@ export const useSaveDoc = (
|
||||
const saveDoc = useCallback(() => {
|
||||
if (!isLocalChange) {
|
||||
return false;
|
||||
} else if (encryptionTransition) {
|
||||
return false;
|
||||
} else if (isEncrypted && (!documentEncryptionSettings || !vaultClient)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
const state = Y.encodeStateAsUpdate(yDoc);
|
||||
|
||||
if (isEncrypted && documentEncryptionSettings && vaultClient) {
|
||||
// Encrypt via vault with ArrayBuffer — zero-copy
|
||||
vaultClient
|
||||
.encryptWithKey(
|
||||
state.buffer as ArrayBuffer,
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
)
|
||||
.then(({ encryptedData }) => {
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(new Uint8Array(encryptedData)),
|
||||
contentEncrypted: true,
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to encrypt document for save:', err);
|
||||
});
|
||||
} else {
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(state),
|
||||
contentEncrypted: false,
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
|
||||
}, [
|
||||
isLocalChange,
|
||||
encryptionTransition,
|
||||
updateDoc,
|
||||
docId,
|
||||
yDoc,
|
||||
isConnectedToCollabServer,
|
||||
isEncrypted,
|
||||
documentEncryptionSettings,
|
||||
vaultClient,
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -65,13 +103,6 @@ export const useSaveDoc = (
|
||||
const onSave = (e?: Event) => {
|
||||
const isSaving = saveDoc();
|
||||
|
||||
/**
|
||||
* Firefox does not trigger the request every time the user leaves the page.
|
||||
* Plus the request is not intercepted by the service worker.
|
||||
* So we prevent the default behavior to have the popup asking the user
|
||||
* if he wants to leave the page, by adding the popup, we let the time to the
|
||||
* request to be sent, and intercepted by the service worker (for the offline part).
|
||||
*/
|
||||
if (
|
||||
isSaving &&
|
||||
typeof e !== 'undefined' &&
|
||||
@@ -82,16 +113,12 @@ export const useSaveDoc = (
|
||||
}
|
||||
};
|
||||
|
||||
// Save every minute
|
||||
const timeout = setInterval(onSave, SAVE_INTERVAL);
|
||||
// Save when the user leaves the page
|
||||
addEventListener('beforeunload', onSave);
|
||||
// Save when the user navigates to another page
|
||||
router.events.on('routeChangeStart', onSave);
|
||||
|
||||
return () => {
|
||||
clearInterval(timeout);
|
||||
|
||||
removeEventListener('beforeunload', onSave);
|
||||
router.events.off('routeChangeStart', onSave);
|
||||
};
|
||||
|
||||
@@ -4,22 +4,43 @@ import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { backendUrl } from '@/api';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import { useCreateDocAttachment } from '../api';
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const useUploadFile = (docId: string) => {
|
||||
export const useUploadFile = (
|
||||
docId: string,
|
||||
encryptedSymmetricKey?: ArrayBuffer,
|
||||
) => {
|
||||
const {
|
||||
mutateAsync: createDocAttachment,
|
||||
isError: isErrorAttachment,
|
||||
error: errorAttachment,
|
||||
} = useCreateDocAttachment();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
if (encryptedSymmetricKey && vaultClient) {
|
||||
// Encrypt the file via vault — pure ArrayBuffer
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
fileBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const encryptedFile = new File([encryptedData], file.name, {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
body.append('file', encryptedFile);
|
||||
body.append('is_encrypted', 'true');
|
||||
} else {
|
||||
body.append('file', file);
|
||||
}
|
||||
|
||||
const ret = await createDocAttachment({
|
||||
docId,
|
||||
@@ -28,7 +49,7 @@ export const useUploadFile = (docId: string) => {
|
||||
|
||||
return `${backendUrl()}${ret.file}`;
|
||||
},
|
||||
[createDocAttachment, docId],
|
||||
[createDocAttachment, docId, encryptedSymmetricKey, vaultClient],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -42,16 +63,10 @@ export const useUploadFile = (docId: string) => {
|
||||
* When we upload a file it can takes some time to analyze it (e.g. virus scan).
|
||||
* This hook listen to upload end and replace the uploaded block by a uploadLoader
|
||||
* block to show analyzing status.
|
||||
* The uploadLoader block will then handle the status display until the analysis is done
|
||||
* then replaced by the final block (e.g. image, pdf, etc.).
|
||||
* @param editor
|
||||
*/
|
||||
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Replace the resource block by a uploadLoader block to show analyzing status
|
||||
*/
|
||||
const replaceBlockWithUploadLoader = useCallback(
|
||||
(block: Block) => {
|
||||
if (
|
||||
@@ -95,7 +110,6 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before accessing document
|
||||
if (!editor?.document) {
|
||||
return;
|
||||
}
|
||||
@@ -110,12 +124,7 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
});
|
||||
}, [editor, replaceBlockWithUploadLoader]);
|
||||
|
||||
/**
|
||||
* Handle upload end to replace the upload block by a uploadLoader
|
||||
* block to show analyzing status
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before setting up handlers
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Doc } from '@/docs/doc-management';
|
||||
interface BoutonShareProps {
|
||||
displayNbAccess: boolean;
|
||||
doc: Doc;
|
||||
hasKeyWarning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
open: () => void;
|
||||
@@ -18,6 +19,7 @@ interface BoutonShareProps {
|
||||
export const BoutonShare = ({
|
||||
displayNbAccess,
|
||||
doc,
|
||||
hasKeyWarning,
|
||||
isDisabled,
|
||||
isHidden,
|
||||
open,
|
||||
@@ -44,6 +46,9 @@ export const BoutonShare = ({
|
||||
if (hasAccesses) {
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4px"
|
||||
$css={css`
|
||||
.c__button--medium {
|
||||
height: var(--c--globals--spacings--lg);
|
||||
@@ -55,9 +60,10 @@ export const BoutonShare = ({
|
||||
<Button
|
||||
aria-label={t('Share button')}
|
||||
variant="secondary"
|
||||
color={hasKeyWarning ? 'warning' : undefined}
|
||||
icon={
|
||||
<Icon
|
||||
iconName="group"
|
||||
iconName={hasKeyWarning ? 'warning' : 'group'}
|
||||
$color="inherit"
|
||||
variant="filled"
|
||||
disabled={isDisabled}
|
||||
@@ -75,8 +81,11 @@ export const BoutonShare = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="brand"
|
||||
color={hasKeyWarning ? 'warning' : 'brand'}
|
||||
variant="tertiary"
|
||||
icon={
|
||||
hasKeyWarning ? <Icon iconName="warning" $color="inherit" /> : undefined
|
||||
}
|
||||
onClick={open}
|
||||
size="medium"
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, HorizontalSeparator } from '@/components';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
@@ -20,9 +21,13 @@ import { DocToolBox } from './DocToolBox';
|
||||
|
||||
interface DocHeaderProps {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings?: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
export const DocHeader = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
}: DocHeaderProps) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -65,7 +70,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
<DocHeaderInfo doc={doc} />
|
||||
</Box>
|
||||
</Box>
|
||||
{!isDeletedDoc && <DocToolBox doc={doc} />}
|
||||
{!isDeletedDoc && (
|
||||
<DocToolBox
|
||||
doc={doc}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
/>
|
||||
)}
|
||||
{isDeletedDoc && (
|
||||
<BoutonShare
|
||||
doc={doc}
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
KEY_LIST_FAVORITE_DOC,
|
||||
ModalEncryptDoc,
|
||||
ModalRemoveDoc,
|
||||
ModalRemoveDocEncryption,
|
||||
getEmojiAndTitle,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
@@ -29,6 +31,11 @@ import {
|
||||
useDocUtils,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import {
|
||||
usePublicKeyRegistry,
|
||||
useUserEncryption,
|
||||
} from '@/docs/doc-collaboration';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { DocShareModal } from '@/docs/doc-share';
|
||||
import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
@@ -44,9 +51,13 @@ const ModalExport = Export?.ModalExport;
|
||||
|
||||
interface DocToolBoxProps {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings?: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
export const DocToolBox = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
}: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -54,12 +65,21 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { isChild, isTopRoot } = useDocUtils(doc);
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { encryptionSettings } = useUserEncryption();
|
||||
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
|
||||
const [isModalEncryptOpen, setIsModalEncryptOpen] = useState(false);
|
||||
const [isModalRemoveEncryptionOpen, setIsModalRemoveEncryptionOpen] =
|
||||
useState(false);
|
||||
const selectHistoryModal = useModal();
|
||||
const modalShare = useModal();
|
||||
|
||||
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
|
||||
undefined,
|
||||
encryptionSettings?.userId,
|
||||
);
|
||||
|
||||
const { isSmallMobile, isMobile } = useResponsiveStore();
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
@@ -125,6 +145,26 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
show: !isMobile,
|
||||
showSeparator: isTopRoot ? true : false,
|
||||
},
|
||||
{
|
||||
label: t('Encrypt document'),
|
||||
icon: 'https',
|
||||
disabled: !doc.abilities.accesses_manage,
|
||||
callback: () => {
|
||||
setIsModalEncryptOpen(true);
|
||||
},
|
||||
show: !doc.is_encrypted && doc.abilities.update,
|
||||
showSeparator: isTopRoot ? true : false,
|
||||
},
|
||||
{
|
||||
label: t('Remove document encryption'),
|
||||
icon: 'no_encryption',
|
||||
disabled: !doc.abilities.accesses_manage,
|
||||
callback: () => {
|
||||
setIsModalRemoveEncryptionOpen(true);
|
||||
},
|
||||
show: doc.is_encrypted && doc.abilities.update,
|
||||
showSeparator: isTopRoot ? true : false,
|
||||
},
|
||||
{
|
||||
label: t('Remove emoji'),
|
||||
icon: 'emoji_emotions',
|
||||
@@ -196,11 +236,34 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
$margin={{ left: 'auto' }}
|
||||
$gap={spacingsTokens['2xs']}
|
||||
>
|
||||
{doc.is_encrypted && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="5px"
|
||||
$css={css`
|
||||
margin-right: 5px;
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
iconName="lock"
|
||||
$size="md"
|
||||
$color={colorsTokens['success-600']}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: '0.8rem', color: colorsTokens['success-600'] }}
|
||||
>
|
||||
{t('Encrypted')}
|
||||
</span>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BoutonShare
|
||||
doc={doc}
|
||||
open={modalShare.open}
|
||||
isHidden={isSmallMobile}
|
||||
displayNbAccess={doc.abilities.accesses_view}
|
||||
hasKeyWarning={hasKeyWarnings}
|
||||
/>
|
||||
|
||||
{!isSmallMobile && ModalExport && (
|
||||
@@ -217,6 +280,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
aria-label={t('Export the document')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
label={t('Open the document options')}
|
||||
@@ -237,6 +301,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
<DocShareModal
|
||||
onClose={() => modalShare.close()}
|
||||
doc={doc}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
isRootDoc={treeContext?.root?.id === doc.id}
|
||||
/>
|
||||
)}
|
||||
@@ -265,6 +330,20 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isModalEncryptOpen && (
|
||||
<ModalEncryptDoc
|
||||
doc={doc}
|
||||
onClose={() => setIsModalEncryptOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isModalRemoveEncryptionOpen &&
|
||||
documentEncryptionSettings?.encryptedSymmetricKey && (
|
||||
<ModalRemoveDocEncryption
|
||||
doc={doc}
|
||||
encryptedSymmetricKey={documentEncryptionSettings.encryptedSymmetricKey}
|
||||
onClose={() => setIsModalRemoveEncryptionOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{selectHistoryModal.isOpen && (
|
||||
<ModalSelectVersion
|
||||
onClose={() => selectHistoryModal.close()}
|
||||
|
||||
@@ -7,6 +7,8 @@ export * from './useDocOptions';
|
||||
export * from './useDocs';
|
||||
export * from './useDocsFavorite';
|
||||
export * from './useDuplicateDoc';
|
||||
export * from './useEncryptDoc';
|
||||
export * from './useRemoveDocEncryption';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useSearchDocs';
|
||||
|
||||
@@ -8,16 +8,19 @@ import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
export type CreateChildDocParam = Pick<Doc, 'title'> & {
|
||||
parentId: string;
|
||||
isEncrypted: boolean;
|
||||
};
|
||||
|
||||
export const createChildDoc = async ({
|
||||
title,
|
||||
parentId,
|
||||
isEncrypted = false,
|
||||
}: CreateChildDocParam): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/${parentId}/children/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
is_encrypted: isEncrypted,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -12,12 +12,16 @@ import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
type CreateDocParams = {
|
||||
title?: string;
|
||||
isEncrypted?: boolean;
|
||||
} | void;
|
||||
|
||||
export const createDoc = async (params: CreateDocParams): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: params?.title }),
|
||||
body: JSON.stringify({
|
||||
title: params?.title,
|
||||
is_encrypted: params?.isEncrypted ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -15,7 +15,9 @@ export type DocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
is_encrypted?: boolean;
|
||||
};
|
||||
|
||||
export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
@@ -30,9 +32,15 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
if (params.is_creator_me !== undefined) {
|
||||
searchParams.set('is_creator_me', params.is_creator_me.toString());
|
||||
}
|
||||
if (params.title && params.title.length > 0) {
|
||||
searchParams.set('title', params.title);
|
||||
}
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
if (params.is_encrypted !== undefined) {
|
||||
searchParams.set('is_encrypted', params.is_encrypted.toString());
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
@@ -75,10 +75,15 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
provider.document.guid === variables.docId;
|
||||
|
||||
if (canSave) {
|
||||
await updateDoc({
|
||||
id: variables.docId,
|
||||
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
|
||||
});
|
||||
const state = Y.encodeStateAsUpdate(provider.document);
|
||||
|
||||
if (state) {
|
||||
await updateDoc({
|
||||
id: variables.docId,
|
||||
content: toBase64(state),
|
||||
contentEncrypted: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await duplicateDoc(variables);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
|
||||
interface EncryptDocProps {
|
||||
docId: string;
|
||||
content: Uint8Array<ArrayBufferLike>;
|
||||
/** Per-user encrypted symmetric keys as base64 strings (from VaultClient.encryptForUsers) */
|
||||
encryptedSymmetricKeyPerUser: Record<string, string>;
|
||||
attachmentKeyMapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const encryptDoc = async ({
|
||||
docId,
|
||||
...params
|
||||
}: EncryptDocProps): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${docId}/encrypt/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
content: toBase64(params.content),
|
||||
encryptedSymmetricKeyPerUser: params.encryptedSymmetricKeyPerUser,
|
||||
attachmentKeyMapping: params.attachmentKeyMapping || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to encrypt the doc',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseEncryptDocOptions = UseMutationOptions<void, APIError, EncryptDocProps>;
|
||||
|
||||
export const useEncryptDoc = ({
|
||||
listInvalidQueries,
|
||||
options,
|
||||
}: {
|
||||
listInvalidQueries?: string[];
|
||||
options?: UseEncryptDocOptions;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, EncryptDocProps>({
|
||||
mutationFn: encryptDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
|
||||
interface RemoveDocEncryptionProps {
|
||||
docId: string;
|
||||
content: Uint8Array<ArrayBufferLike>;
|
||||
attachmentKeyMapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const removeDocEncryption = async ({
|
||||
docId,
|
||||
...params
|
||||
}: RemoveDocEncryptionProps): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${docId}/remove-encryption/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
content: toBase64(params.content),
|
||||
attachmentKeyMapping: params.attachmentKeyMapping || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to remove encryption from the doc',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseRemoveDocEncryptionOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
RemoveDocEncryptionProps
|
||||
>;
|
||||
|
||||
export const useRemoveDocEncryption = ({
|
||||
listInvalidQueries,
|
||||
options,
|
||||
}: {
|
||||
listInvalidQueries?: string[];
|
||||
options?: UseRemoveDocEncryptionOptions;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, RemoveDocEncryptionProps>({
|
||||
mutationFn: removeDocEncryption,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
import { DocsResponse, KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
export type SearchDocsParams = {
|
||||
page: number;
|
||||
q: string;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
const constructParams = ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): URLSearchParams => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('q', q);
|
||||
|
||||
if (target === DocSearchTarget.CURRENT && parentPath) {
|
||||
searchParams.set('path', parentPath);
|
||||
}
|
||||
if (page) {
|
||||
searchParams.set('page', page.toString());
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
const searchDocs = async ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams({ q, page, target, parentPath });
|
||||
const response = await fetchAPI(
|
||||
`documents/search/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const useSearchDocs = (
|
||||
{ q, page, target, parentPath }: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_DOC, 'search', { q, page, target, parentPath }],
|
||||
queryFn: () => searchDocs({ q, page, target, parentPath }),
|
||||
...queryConfig,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInfiniteSearchDocs = (
|
||||
params: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_DOC, searchDocs, params, queryConfig);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
InfiniteQueryConfig,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocsOrdering } from '../types';
|
||||
|
||||
import { DocsResponse, constructParams } from './useDocs';
|
||||
|
||||
export type SubDocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
is_encrypted?: boolean;
|
||||
parent_id: string;
|
||||
};
|
||||
|
||||
export const getSubDocs = async (
|
||||
params: SubDocsParams,
|
||||
): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams(params);
|
||||
searchParams.set('parent_id', params.parent_id);
|
||||
|
||||
const response: Response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the sub docs',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_SUB_DOC = 'sub-docs';
|
||||
|
||||
export function useSubDocs(
|
||||
params: SubDocsParams,
|
||||
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_SUB_DOC, params],
|
||||
queryFn: () => getSubDocs(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteSubDocs = (
|
||||
params: SubDocsParams,
|
||||
queryConfig?: InfiniteQueryConfig<DocsResponse>,
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { KEY_CAN_EDIT } from './useDocCanEdit';
|
||||
|
||||
export type UpdateDocParams = Pick<Doc, 'id'> &
|
||||
Partial<Pick<Doc, 'content' | 'title'>> & {
|
||||
contentEncrypted?: boolean;
|
||||
websocket?: boolean;
|
||||
};
|
||||
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<svg
|
||||
width="33"
|
||||
height="33"
|
||||
viewBox="0 0 33 33"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_3236_2932)">
|
||||
<g clip-path="url(#clip1_3236_2932)">
|
||||
<rect x="4.5" y="0.5" width="24" height="32" rx="3.55556" fill="white" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 7.7623C8.08374 7.27138 8.48171 6.87341 8.97263 6.87341H16.9726C17.4635 6.87341 17.8615 7.27138 17.8615 7.7623C17.8615 8.25322 17.4635 8.65118 16.9726 8.65118H8.97263C8.48171 8.65118 8.08374 8.25322 8.08374 7.7623Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 10.7685C8.08374 10.2776 8.48171 9.87964 8.97263 9.87964H24.0273C24.5182 9.87964 24.9162 10.2776 24.9162 10.7685C24.9162 11.2594 24.5182 11.6574 24.0273 11.6574H8.97263C8.48171 11.6574 8.08374 11.2594 8.08374 10.7685ZM8.08374 13.4352C8.08374 12.9443 8.48171 12.5463 8.97263 12.5463H24.0273C24.5182 12.5463 24.9162 12.9443 24.9162 13.4352C24.9162 13.9261 24.5182 14.3241 24.0273 14.3241H8.97263C8.48171 14.3241 8.08374 13.9261 8.08374 13.4352ZM8.08374 16.1019C8.08374 15.6109 8.48171 15.213 8.97263 15.213H24.0273C24.5182 15.213 24.9162 15.6109 24.9162 16.1019C24.9162 16.5928 24.5182 16.9907 24.0273 16.9907H8.97263C8.48171 16.9907 8.08374 16.5928 8.08374 16.1019ZM8.08374 18.7685C8.08374 18.2776 8.48171 17.8796 8.97263 17.8796H24.0273C24.5182 17.8796 24.9162 18.2776 24.9162 18.7685C24.9162 19.2594 24.5182 19.6574 24.0273 19.6574H8.97263C8.48171 19.6574 8.08374 19.2594 8.08374 18.7685Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 10.7685C8.08374 10.2776 8.48171 9.87964 8.97263 9.87964H24.0273C24.5182 9.87964 24.9162 10.2776 24.9162 10.7685C24.9162 11.2594 24.5182 11.6574 24.0273 11.6574H8.97263C8.48171 11.6574 8.08374 11.2594 8.08374 10.7685ZM8.08374 13.4352C8.08374 12.9443 8.48171 12.5463 8.97263 12.5463H24.0273C24.5182 12.5463 24.9162 12.9443 24.9162 13.4352C24.9162 13.9261 24.5182 14.3241 24.0273 14.3241H8.97263C8.48171 14.3241 8.08374 13.9261 8.08374 13.4352ZM8.08374 16.1019C8.08374 15.6109 8.48171 15.213 8.97263 15.213H24.0273C24.5182 15.213 24.9162 15.6109 24.9162 16.1019C24.9162 16.5928 24.5182 16.9907 24.0273 16.9907H8.97263C8.48171 16.9907 8.08374 16.5928 8.08374 16.1019ZM8.08374 18.7685C8.08374 18.2776 8.48171 17.8796 8.97263 17.8796H24.0273C24.5182 17.8796 24.9162 18.2776 24.9162 18.7685C24.9162 19.2594 24.5182 19.6574 24.0273 19.6574H8.97263C8.48171 19.6574 8.08374 19.2594 8.08374 18.7685Z"
|
||||
fill="white"
|
||||
fill-opacity="0.65"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 21.4666C8.08374 20.9757 8.48171 20.5777 8.97263 20.5777H24.0273C24.5182 20.5777 24.9162 20.9757 24.9162 21.4666C24.9162 21.9575 24.5182 22.3555 24.0273 22.3555H8.97263C8.48171 22.3555 8.08374 21.9575 8.08374 21.4666ZM8.08374 24.1333C8.08374 23.6424 8.48171 23.2444 8.97263 23.2444H24.0273C24.5182 23.2444 24.9162 23.6424 24.9162 24.1333C24.9162 24.6242 24.5182 25.0222 24.0273 25.0222H8.97263C8.48171 25.0222 8.08374 24.6242 8.08374 24.1333ZM8.08374 26.8C8.08374 26.309 8.48171 25.9111 8.97263 25.9111H24.0273C24.5182 25.9111 24.9162 26.309 24.9162 26.8C24.9162 27.2909 24.5182 27.6888 24.0273 27.6888H8.97263C8.48171 27.6888 8.08374 27.2909 8.08374 26.8ZM8.08374 29.4666C8.08374 28.9757 8.48171 28.5777 8.97263 28.5777H24.0273C24.5182 28.5777 24.9162 28.9757 24.9162 29.4666C24.9162 29.9575 24.5182 30.3555 24.0273 30.3555H8.97263C8.48171 30.3555 8.08374 29.9575 8.08374 29.4666Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 21.4666C8.08374 20.9757 8.48171 20.5777 8.97263 20.5777H24.0273C24.5182 20.5777 24.9162 20.9757 24.9162 21.4666C24.9162 21.9575 24.5182 22.3555 24.0273 22.3555H8.97263C8.48171 22.3555 8.08374 21.9575 8.08374 21.4666ZM8.08374 24.1333C8.08374 23.6424 8.48171 23.2444 8.97263 23.2444H24.0273C24.5182 23.2444 24.9162 23.6424 24.9162 24.1333C24.9162 24.6242 24.5182 25.0222 24.0273 25.0222H8.97263C8.48171 25.0222 8.08374 24.6242 8.08374 24.1333ZM8.08374 26.8C8.08374 26.309 8.48171 25.9111 8.97263 25.9111H24.0273C24.5182 25.9111 24.9162 26.309 24.9162 26.8C24.9162 27.2909 24.5182 27.6888 24.0273 27.6888H8.97263C8.48171 27.6888 8.08374 27.2909 8.08374 26.8ZM8.08374 29.4666C8.08374 28.9757 8.48171 28.5777 8.97263 28.5777H24.0273C24.5182 28.5777 24.9162 28.9757 24.9162 29.4666C24.9162 29.9575 24.5182 30.3555 24.0273 30.3555H8.97263C8.48171 30.3555 8.08374 29.9575 8.08374 29.4666Z"
|
||||
fill="white"
|
||||
fill-opacity="0.65"
|
||||
/>
|
||||
<rect
|
||||
x="4.57422"
|
||||
y="0.5"
|
||||
width="23.9258"
|
||||
height="31.8206"
|
||||
fill="url(#paint0_linear_3236_2932)"
|
||||
fill-opacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="4.85"
|
||||
y="0.85"
|
||||
width="23.3"
|
||||
height="31.3"
|
||||
rx="3.20556"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="4.85"
|
||||
y="0.85"
|
||||
width="23.3"
|
||||
height="31.3"
|
||||
rx="3.20556"
|
||||
stroke="white"
|
||||
stroke-opacity="0.65"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="4.85"
|
||||
y="0.85"
|
||||
width="23.3"
|
||||
height="31.3"
|
||||
rx="3.20556"
|
||||
stroke="url(#paint1_linear_3236_2932)"
|
||||
stroke-opacity="0.23"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="10.0132"
|
||||
y="10.0132"
|
||||
width="12.9736"
|
||||
height="12.9736"
|
||||
rx="6.48682"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="10.0132"
|
||||
y="10.0132"
|
||||
width="12.9736"
|
||||
height="12.9736"
|
||||
rx="6.48682"
|
||||
stroke="white"
|
||||
stroke-width="1.21628"
|
||||
/>
|
||||
<g transform="translate(16.5 16.5) scale(0.27)">
|
||||
<path transform="translate(-12 -12)" d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_3236_2932"
|
||||
x1="16.5371"
|
||||
y1="0.5"
|
||||
x2="16.5371"
|
||||
y2="32.3206"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="white" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3236_2932"
|
||||
x1="16.5"
|
||||
y1="0.5"
|
||||
x2="16.5"
|
||||
y2="32.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="white" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3236_2932">
|
||||
<rect
|
||||
width="32"
|
||||
height="32"
|
||||
fill="white"
|
||||
transform="translate(0.5 0.5)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3236_2932">
|
||||
<rect x="4.5" y="0.5" width="24" height="32" rx="3.55556" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
+467
@@ -0,0 +1,467 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
import { useUserEncryption } from '@/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { createDocAttachment } from '@/docs/doc-editor/api';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
LinkReach,
|
||||
extractAttachmentKeysAndMetadata,
|
||||
getDocLinkReach,
|
||||
useEncryptDoc,
|
||||
useProviderStore,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useDocAccesses } from '@/features/docs/doc-share/api/useDocAccesses';
|
||||
import { useDocInvitations } from '@/features/docs/doc-share/api/useDocInvitations';
|
||||
import { useKeyboardAction } from '@/hooks';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
/**
|
||||
* encrypt existing unencrypted attachments and return:
|
||||
* - a modified Yjs state with URLs pointing to new encrypted files
|
||||
* - a mapping of old S3 keys to new ones (for backend cleanup)
|
||||
*
|
||||
* originals are never modified so if the process fails midway the document
|
||||
* still works with its original unencrypted attachments.
|
||||
*/
|
||||
const encryptRemoteAttachments = async (
|
||||
yDoc: Y.Doc,
|
||||
docId: string,
|
||||
vaultClient: VaultClient,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
): Promise<Record<string, string>> => {
|
||||
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
|
||||
|
||||
// if no attachment it's straightforward
|
||||
if (attachmentKeysAndMetadata.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// otherwise upload encrypted copies as new attachments and collect the mapping
|
||||
const attachmentKeyMapping: Record<string, string> = {};
|
||||
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const response = await fetch(oldAttachmentMetadata.mediaUrl, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('attachment cannot be fetch');
|
||||
}
|
||||
|
||||
// Encrypt file via vault — pure ArrayBuffer, no base64 conversion
|
||||
const fileBuffer = await response.arrayBuffer();
|
||||
const { encryptedData: encryptedBuffer } = await vaultClient.encryptWithKey(
|
||||
fileBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
const encryptedBytes = new Uint8Array(encryptedBuffer);
|
||||
|
||||
const fileName = oldAttachmentMetadata.name ?? 'file'; // since encrypted we could not reuse the file name that can be stored as clear text
|
||||
const encryptedFile = new File([encryptedBytes], fileName, {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', encryptedFile);
|
||||
body.append('is_encrypted', 'true');
|
||||
|
||||
const result = await createDocAttachment({ docId, body });
|
||||
|
||||
// result.file is like "/api/v1.0/documents/{id}/media-check/?key={newKey}"
|
||||
const newKey = new URL(
|
||||
result.file,
|
||||
window.location.origin,
|
||||
).searchParams.get('key');
|
||||
|
||||
if (!newKey) {
|
||||
throw new Error('file key must be provided once uploaded');
|
||||
}
|
||||
|
||||
attachmentKeyMapping[oldAttachmentKey] = newKey;
|
||||
}
|
||||
|
||||
// once uploaded, we can update all nodes referencing attachments with their new key
|
||||
yDoc.transact(() => {
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const newMediaUrl = oldAttachmentMetadata.mediaUrl.replace(
|
||||
oldAttachmentKey,
|
||||
attachmentKeyMapping[oldAttachmentKey],
|
||||
);
|
||||
|
||||
for (const node of oldAttachmentMetadata.nodes) {
|
||||
node.setAttribute('url', newMediaUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return attachmentKeyMapping;
|
||||
};
|
||||
|
||||
interface ModalEncryptDocProps {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
const { user } = useAuth();
|
||||
const { encryptionSettings } = useUserEncryption();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: encryptDoc,
|
||||
isError,
|
||||
error,
|
||||
} = useEncryptDoc({
|
||||
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
});
|
||||
|
||||
const { data: invitationsData } = useDocInvitations({
|
||||
docId: doc.id,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const { data: accesses } = useDocAccesses({ docId: doc.id });
|
||||
|
||||
const keyboardAction = useKeyboardAction();
|
||||
|
||||
const effectiveReach = getDocLinkReach(doc);
|
||||
const isRestricted = effectiveReach === LinkReach.RESTRICTED;
|
||||
const hasPendingInvitations = !!invitationsData && invitationsData.count > 0;
|
||||
|
||||
// Fetch public keys from the encryption service to check who has encryption enabled
|
||||
const [publicKeysMap, setPublicKeysMap] = useState<Record<string, ArrayBuffer>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accesses || !vaultClient) return;
|
||||
|
||||
const userIds = accesses
|
||||
.filter((a) => a.user?.suite_user_id)
|
||||
.map((a) => a.user.suite_user_id!);
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
vaultClient.fetchPublicKeys(userIds)
|
||||
.then(({ publicKeys }) => setPublicKeysMap(publicKeys))
|
||||
.catch(() => {});
|
||||
}, [accesses, vaultClient]);
|
||||
|
||||
const membersWithoutKey = useMemo(() => {
|
||||
if (!accesses) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return accesses.filter(
|
||||
(access) => access.user?.suite_user_id && !publicKeysMap[access.user.suite_user_id],
|
||||
);
|
||||
}, [accesses, publicKeysMap]);
|
||||
|
||||
const hasEncryptionKeys = !!encryptionSettings;
|
||||
|
||||
const canEncrypt =
|
||||
hasEncryptionKeys &&
|
||||
isRestricted &&
|
||||
!hasPendingInvitations &&
|
||||
membersWithoutKey.length === 0;
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEncrypt = async () => {
|
||||
if (!provider || !user || isPending || !canEncrypt || !encryptionSettings || !vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
|
||||
|
||||
if (Object.keys(publicKeysMap).length === 0) {
|
||||
throw new Error('No public keys available. All members must have encryption enabled.');
|
||||
}
|
||||
|
||||
// Clone the Yjs document for encryption
|
||||
const ongoingDoc = new Y.Doc();
|
||||
Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document));
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
|
||||
// Encrypt document content via vault — pure ArrayBuffer
|
||||
const { encryptedContent: encryptedContentBuffer, encryptedKeys } =
|
||||
await vaultClient.encryptForUsers(
|
||||
ongoingDocState.buffer as ArrayBuffer,
|
||||
publicKeysMap,
|
||||
);
|
||||
|
||||
// Convert ArrayBuffer encrypted keys to base64 for the backend API
|
||||
const encryptedSymmetricKeyPerUser: Record<string, string> = {};
|
||||
|
||||
for (const [uid, keyBuffer] of Object.entries(encryptedKeys)) {
|
||||
encryptedSymmetricKeyPerUser[uid] = toBase64(new Uint8Array(keyBuffer));
|
||||
}
|
||||
|
||||
// Get the current user's encrypted key for attachment encryption
|
||||
const currentUserEncryptedKey = user.suite_user_id ? encryptedKeys[user.suite_user_id] : undefined;
|
||||
|
||||
// Encrypt existing attachments using the same symmetric key via vault
|
||||
let attachmentKeyMapping: Record<string, string> = {};
|
||||
|
||||
if (currentUserEncryptedKey) {
|
||||
attachmentKeyMapping = await encryptRemoteAttachments(
|
||||
ongoingDoc,
|
||||
doc.id,
|
||||
vaultClient,
|
||||
currentUserEncryptedKey,
|
||||
);
|
||||
}
|
||||
|
||||
ongoingDoc.destroy();
|
||||
|
||||
const encryptedContent = new Uint8Array(encryptedContentBuffer);
|
||||
|
||||
await encryptDoc({
|
||||
docId: doc.id,
|
||||
content: encryptedContent,
|
||||
encryptedSymmetricKeyPerUser,
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
toast(t('The document has been encrypted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
// notify other users before destroying the provider since websocket connection needed
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED);
|
||||
|
||||
// trigger the provider switch (hocuspocus → relay)
|
||||
startEncryptionTransition('encrypting');
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_CANCELED);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseKeyDown = keyboardAction(handleClose);
|
||||
const handleEncryptKeyDown = keyboardAction(handleEncrypt);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside={!isPending}
|
||||
hideCloseButton
|
||||
onClose={handleClose}
|
||||
aria-describedby="modal-encrypt-doc-title"
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
fullWidth
|
||||
onClick={handleEncrypt}
|
||||
onKeyDown={handleEncryptKeyDown}
|
||||
disabled={isPending || !canEncrypt}
|
||||
icon={
|
||||
isPending ? (
|
||||
<div>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
>
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h1"
|
||||
id="modal-encrypt-doc-title"
|
||||
$margin="0"
|
||||
$align="flex-start"
|
||||
>
|
||||
{t('Encrypt document')}
|
||||
</Text>
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the encrypt modal')}
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box className="--docs--modal-encrypt-doc" $gap="sm">
|
||||
{!isError && (
|
||||
<Box $gap="sm">
|
||||
<Alert type={VariantType.WARNING}>
|
||||
<Box $gap="xs">
|
||||
<Text $size="sm">
|
||||
{t(
|
||||
'Encrypting a document ensures that only authorized members can read its content. Keep in mind before proceeding any access will then require its user to do the encryption onboarding, with the complication of ensuring keys backups.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<Text $size="sm" $variation="secondary">
|
||||
{t('Here the conditions that must be met:')}
|
||||
</Text>
|
||||
|
||||
<Box $gap="xs">
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={hasEncryptionKeys ? 'check_circle' : 'cancel'}
|
||||
$size="sm"
|
||||
$theme={hasEncryptionKeys ? 'success' : 'error'}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={hasEncryptionKeys ? '400' : '600'}
|
||||
$theme={hasEncryptionKeys ? undefined : 'error'}
|
||||
>
|
||||
{hasEncryptionKeys
|
||||
? t('Encryption is enabled on your account')
|
||||
: t(
|
||||
'You must enable encryption from your account menu first',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={isRestricted ? 'check_circle' : 'cancel'}
|
||||
$size="sm"
|
||||
$theme={isRestricted ? 'success' : 'error'}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={isRestricted ? '400' : '600'}
|
||||
$theme={isRestricted ? undefined : 'error'}
|
||||
>
|
||||
{isRestricted
|
||||
? t('Document access is private')
|
||||
: t(
|
||||
'Document must be set to private (currently {{reach}})',
|
||||
{
|
||||
reach:
|
||||
effectiveReach === LinkReach.PUBLIC
|
||||
? t('public')
|
||||
: t('connected'),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={!hasPendingInvitations ? 'check_circle' : 'cancel'}
|
||||
$size="sm"
|
||||
$theme={!hasPendingInvitations ? 'success' : 'error'}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={!hasPendingInvitations ? '400' : '600'}
|
||||
$theme={!hasPendingInvitations ? undefined : 'error'}
|
||||
>
|
||||
{!hasPendingInvitations
|
||||
? t('No pending invitations')
|
||||
: t('Pending invitations must be resolved first')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $gap="3xs">
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={
|
||||
membersWithoutKey.length === 0 ? 'check_circle' : 'cancel'
|
||||
}
|
||||
$size="sm"
|
||||
$theme={
|
||||
membersWithoutKey.length === 0 ? 'success' : 'error'
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={membersWithoutKey.length === 0 ? '400' : '600'}
|
||||
$theme={
|
||||
membersWithoutKey.length === 0 ? undefined : 'error'
|
||||
}
|
||||
>
|
||||
{membersWithoutKey.length === 0
|
||||
? t('All members have encryption enabled')
|
||||
: t(
|
||||
'{{count}} member(s) have not enabled encryption yet',
|
||||
{ count: membersWithoutKey.length },
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
{membersWithoutKey.length > 0 && (
|
||||
<Box $margin={{ left: 'sm' }} $gap="3xs">
|
||||
{membersWithoutKey.map((access) => (
|
||||
<Text key={access.id} $size="xs" $variation="secondary">
|
||||
{access.user.full_name || access.user.email}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && <TextErrors causes={error.cause} />}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
|
||||
import { createDocAttachment } from '@/docs/doc-editor/api';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
extractAttachmentKeysAndMetadata,
|
||||
useProviderStore,
|
||||
useRemoveDocEncryption,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useKeyboardAction } from '@/hooks';
|
||||
|
||||
/**
|
||||
* Decrypt existing encrypted attachments using the vault and upload decrypted copies.
|
||||
*/
|
||||
const decryptRemoteAttachments = async (
|
||||
yDoc: Y.Doc,
|
||||
docId: string,
|
||||
vaultClient: VaultClient,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
): Promise<Record<string, string>> => {
|
||||
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
|
||||
|
||||
if (attachmentKeysAndMetadata.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const attachmentKeyMapping: Record<string, string> = {};
|
||||
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const response = await fetch(oldAttachmentMetadata.mediaUrl, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('attachment cannot be fetched');
|
||||
}
|
||||
|
||||
// Decrypt via vault — pure ArrayBuffer
|
||||
const encryptedBuffer = await response.arrayBuffer();
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
encryptedBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const fileName = oldAttachmentMetadata.name ?? 'file';
|
||||
const decryptedFile = new File([decryptedBuffer], fileName);
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', decryptedFile);
|
||||
|
||||
const result = await createDocAttachment({ docId, body });
|
||||
|
||||
const newKey = new URL(
|
||||
result.file,
|
||||
window.location.origin,
|
||||
).searchParams.get('key');
|
||||
|
||||
if (!newKey) {
|
||||
throw new Error('file key must be provided once uploaded');
|
||||
}
|
||||
|
||||
attachmentKeyMapping[oldAttachmentKey] = newKey;
|
||||
}
|
||||
|
||||
yDoc.transact(() => {
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const newMediaUrl = oldAttachmentMetadata.mediaUrl.replace(
|
||||
oldAttachmentKey,
|
||||
attachmentKeyMapping[oldAttachmentKey],
|
||||
);
|
||||
|
||||
for (const node of oldAttachmentMetadata.nodes) {
|
||||
node.setAttribute('url', newMediaUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return attachmentKeyMapping;
|
||||
};
|
||||
|
||||
interface ModalRemoveDocEncryptionProps {
|
||||
doc: Doc;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ModalRemoveDocEncryption = ({
|
||||
doc,
|
||||
encryptedSymmetricKey,
|
||||
onClose,
|
||||
}: ModalRemoveDocEncryptionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: removeDocEncryption,
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveDocEncryption({
|
||||
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
});
|
||||
|
||||
const keyboardAction = useKeyboardAction();
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveEncryption = async () => {
|
||||
if (!provider || isPending || !vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED);
|
||||
|
||||
const ongoingDoc = new Y.Doc();
|
||||
Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document));
|
||||
|
||||
const attachmentKeyMapping = await decryptRemoteAttachments(
|
||||
ongoingDoc,
|
||||
doc.id,
|
||||
vaultClient,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
ongoingDoc.destroy();
|
||||
|
||||
await removeDocEncryption({
|
||||
docId: doc.id,
|
||||
content: ongoingDocState,
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
toast(t('Encryption has been removed.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED);
|
||||
startEncryptionTransition('removing-encryption');
|
||||
} catch (err) {
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={handleClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
rightActions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={isPending}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
onClick={() => void handleRemoveEncryption()}
|
||||
disabled={isPending}
|
||||
{...keyboardAction(() => void handleRemoveEncryption())}
|
||||
>
|
||||
{isPending ? <Spinner /> : t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
title={
|
||||
<Box $direction="row" $justify="space-between" $align="center">
|
||||
<Text as="h1" $size="h6" $align="flex-start" $margin="0">
|
||||
{t('Remove document encryption')}
|
||||
</Text>
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the modal')}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<Box $margin={{ top: 'sm' }} $gap="sm">
|
||||
<Text $variation="secondary">
|
||||
{t(
|
||||
'This will permanently remove encryption from this document. All content will be stored in plain text.',
|
||||
)}
|
||||
</Text>
|
||||
{isError && error && <TextErrors causes={error.cause} />}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+24
-2
@@ -1,12 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useDate } from '@/hooks/useDate';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import ChildDocument from '../assets/child-document.svg';
|
||||
import EncryptedDocumentIcon from '../assets/encrypted-document.svg';
|
||||
import PinnedDocumentIcon from '../assets/pinned-document.svg';
|
||||
import SimpleFileIcon from '../assets/simple-document.svg';
|
||||
import { useDocUtils, useTrans } from '../hooks';
|
||||
@@ -25,12 +26,14 @@ const ItemTextCss = css`
|
||||
type SimpleDocItemProps = {
|
||||
doc: Doc;
|
||||
isPinned?: boolean;
|
||||
isEncrypted?: boolean;
|
||||
showAccesses?: boolean;
|
||||
};
|
||||
|
||||
export const SimpleDocItem = ({
|
||||
doc,
|
||||
isPinned = false,
|
||||
isEncrypted = false,
|
||||
showAccesses = false,
|
||||
}: SimpleDocItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -56,12 +59,19 @@ export const SimpleDocItem = ({
|
||||
$css={css`
|
||||
background-color: transparent;
|
||||
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
|
||||
position: relative;
|
||||
`}
|
||||
$padding={`${spacingsTokens['3xs']} 0`}
|
||||
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isPinned ? (
|
||||
{isEncrypted ? (
|
||||
<EncryptedDocumentIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-encryption-icon"
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
) : isPinned ? (
|
||||
<PinnedDocumentIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-pinned-icon"
|
||||
@@ -82,6 +92,18 @@ export const SimpleDocItem = ({
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
)}
|
||||
{isPinned && isEncrypted && (
|
||||
<Icon
|
||||
iconName="push_pin"
|
||||
$size="12px"
|
||||
$color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: -4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box $justify="center" $overflow="auto">
|
||||
<Text
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user