This commit is contained in:
Thomas Ramé
2026-03-02 16:52:31 +01:00
parent bedb0573b8
commit 9c438eba06
83 changed files with 3033 additions and 1197 deletions
+22 -1
View File
@@ -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):
"""
+116 -1
View File
@@ -32,7 +32,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
fields = ["id", "email", "full_name", "short_name", "encryption_public_key", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
def get_full_name(self, instance):
@@ -65,17 +65,23 @@ 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_public_keys_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_public_keys_per_user",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
@@ -84,8 +90,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 +107,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
read_only_fields = [
"id",
"abilities",
"accesses_public_keys_per_user",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
@@ -107,8 +116,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 +162,28 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"""Return the deleted_at of the current document."""
return instance.ancestors_deleted_at
def get_accesses_public_keys_per_user(self, instance):
"""Return public keys of users with access, only for encrypted documents."""
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return None
return instance.accesses_public_keys_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 +198,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 +209,22 @@ class DocumentSerializer(ListDocumentSerializer):
fields = [
"id",
"abilities",
"accesses_public_keys_per_user",
"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 +247,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 +268,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_public_keys_per_user", None)
fields.pop("encrypted_document_symmetric_key_for_user", None)
return fields
def validate_id(self, value):
@@ -343,6 +388,9 @@ 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
)
class Meta:
model = models.DocumentAccess
@@ -357,6 +405,7 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
"abilities",
"max_ancestors_role",
"max_role",
"encrypted_document_symmetric_key_for_user",
]
read_only_fields = [
"id",
@@ -366,6 +415,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")
@@ -856,6 +928,49 @@ 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)
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)
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
+228 -1
View File
@@ -168,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
@@ -354,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:
@@ -365,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.
@@ -613,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
@@ -1937,6 +1975,155 @@ 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"]
# 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 we have keys for all users with access to this document
# Get all user IDs that have access to this document
document_accesses = models.DocumentAccess.objects.filter(document=document, user__isnull=False)
users_with_access = {str(access.user_id) 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.'
})
# 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()
# Store the encrypted symmetric keys in DocumentAccess for each user
for user_id, encrypted_key in encryptedSymmetricKeyPerUser.items():
try:
# Find the DocumentAccess record for this user and document
access = models.DocumentAccess.objects.get(document=document, user_id=user_id)
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"]
# 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
)
# Return the updated document
serializer = self.get_serializer(document)
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -2098,6 +2285,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:
@@ -2112,6 +2309,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
@@ -2221,6 +2426,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(
@@ -2230,6 +2444,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,
@@ -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'),
),
]
+38
View File
@@ -186,6 +186,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
default=False,
help_text=_("Whether the user can log into this admin site."),
)
encryption_public_key = models.TextField(
_("encryption public key"),
null=True,
blank=True,
help_text=_("Public key for end-to-end encryption."),
)
is_active = models.BooleanField(
_("active"),
default=True,
@@ -279,6 +285,12 @@ 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."),
)
class Meta:
abstract = True
@@ -361,6 +373,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 +731,29 @@ class Document(MP_Node, BaseModel):
"""Actual link role on the document."""
return self.computed_link_definition["link_role"]
@property
def accesses_public_keys_per_user(self):
"""
Return public keys of users with access to this document.
Returns a dictionary mapping user IDs to their encryption public keys.
Available for all documents so that encryption can be initiated
on non-encrypted documents too.
"""
# Get all users with direct access to this document
users_with_access = (
DocumentAccess.objects
.filter(document=self, user__isnull=False)
.select_related('user')
.values_list('user_id', 'user__encryption_public_key')
)
# Convert to dictionary: {user_id: public_key}
return {
str(user_id): public_key
for user_id, public_key in users_with_access
if public_key # Only include users with public keys
}
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
@@ -797,12 +833,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,
@@ -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,
@@ -43,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,
@@ -68,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,
@@ -91,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,
@@ -143,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,
@@ -166,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,
@@ -189,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,
@@ -262,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,
@@ -285,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,
@@ -308,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,
@@ -366,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,
@@ -389,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,
@@ -412,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,
@@ -491,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,
@@ -514,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,
@@ -537,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,
@@ -596,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,
@@ -619,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,
@@ -642,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,
@@ -747,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,
@@ -770,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,
@@ -793,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,
@@ -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,
@@ -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,
@@ -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 -2
View File
@@ -44,7 +44,7 @@
"@sentry/nextjs": "10.34.0",
"@tanstack/react-query": "5.90.18",
"@tiptap/extensions": "*",
"@y/websocket-server": "^0.1.1",
"async-mutex": "^0.5.0",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -55,7 +55,6 @@
"i18next": "25.7.4",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"js-base64": "^3.7.8",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.5.9",
@@ -4,6 +4,7 @@
* @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} encryptionPublicKey - The user public key if encryption onboarding has been done.
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
*/
export interface User {
@@ -11,6 +12,7 @@ export interface User {
email: string;
full_name: string;
short_name: string;
encryption_public_key: string | null;
language?: string;
}
@@ -0,0 +1,135 @@
import type { MessageEvent } from 'ws';
import {
decryptContent,
encryptContent,
} from '@/docs/doc-collaboration/encryption';
export class EncryptedWebSocket extends WebSocket {
protected readonly encryptionKey!: CryptoKey;
protected readonly decryptionKey!: CryptoKey;
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;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!(messageEvent.data instanceof ArrayBuffer)) {
throw new Error(
`the data over the wire should always be ArrayBuffer since defined on the websocket property "binaryType"`,
);
}
const manageableData = new Uint8Array<ArrayBuffer>(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
messageEvent.data as ArrayBuffer,
);
const decryptedData = await decryptContent(
manageableData,
this.decryptionKey,
);
if (typeof listener === 'function') {
listener.call(this, { ...event, data: decryptedData });
} else {
listener.handleEvent.call(this, {
...event,
data: decryptedData,
});
}
};
originalAddEventListener('message', wrappedListener, options);
} else {
originalAddEventListener(type, listener, options);
}
};
// In case it's added directly with `onmessage` and since we cannot override the setter of `onmessage`
// tweak a bit to intercept when setting it
// const base = Object.getPrototypeOf(this) as WebSocket;
// const baseDesc = Object.getOwnPropertyDescriptor(base, 'onmessage')!;
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() {
console.log('GETTING ONMESSAGE');
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 by "y-websocket", instead it should be patched to use "addEventListener" since we want to extend it to decrypt messages but "defineProperty" is not working on the instance, probably it should be done on the prototype but it would mess with other WebSocket usage. SO PLEASE RUN "yarn run patch-package"!`,
);
// if (!handler) {
// explicitlySetListener = null;
// return;
// }
// explicitlySetListener = function (
// this: WebSocket,
// event: MessageEvent,
// ) {
// if (!(event.data instanceof ArrayBuffer)) {
// throw new Error(
// `the data over the wire should always be ArrayBuffer since defined on the websocket property "binaryType"`,
// );
// }
// const manageableData = new Uint8Array(event.data);
// return handler.call(this, { ...event, data: decrypt(manageableData) });
// };
},
});
}
send(message: Uint8Array<ArrayBuffer>) {
// TODO: we use the polyfilled websocket parameter for `y-websocket` to bring our own encryption logic over the network
// that's great but encryption is preferable with async processes, we cannot just switch to async since
// it's used into the Yjs websocket provider.
//
// try like this since no return value is expected from here, but it will mess in case of error (unhandled exception...)
// if it does not fit our need, we will have to rewrite the Yjs websocket package to have the best async logic set up
encryptContent(message, this.encryptionKey)
.then((encryptedMessage) => {
super.send(encryptedMessage);
})
.catch((error) => {
console.error(error);
return Promise.reject(error);
});
}
}
export function createAdaptedEncryptedWebsocketClass(options: {
encryptionKey: CryptoKey;
decryptionKey: CryptoKey;
}) {
return class extends EncryptedWebSocket {
protected readonly encryptionKey = options.encryptionKey;
protected readonly decryptionKey = options.decryptionKey;
};
}
@@ -0,0 +1,125 @@
const userKeyPairAlgorithm = 'RSA-OAEP';
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);
}
// 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;
}
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { decryptSymmetricKey } from '@/docs/doc-collaboration/encryption';
import assert from 'assert';
export function useDocumentEncryption(
encryptionLoading: boolean,
encryptionSettings: {
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null,
isDocumentEncrypted: boolean | undefined,
userEncryptedSymmetricKey: string | undefined,
): {
documentEncryptionLoading: boolean;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
} {
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState<{
documentSymmetricKey: CryptoKey;
} | null>(null);
useEffect(() => {
let cancelled = false;
async function initDocumentEncryption() {
// Waiting for global encryption settings to be ready, or for asynchronous document data to be fetch
if (!encryptionLoading && !encryptionSettings) {
setLoading(false);
setSettings(null);
return;
} else if (encryptionLoading || isDocumentEncrypted === undefined) {
setLoading(true);
setSettings(null);
return;
} else if (isDocumentEncrypted === false) {
setLoading(false);
setSettings(null);
return;
}
// TODO:
// TODO: if encrypted but there is no encrypted symmetric key for this user, we should display an error
// TODO: (maybe reuse the catch handler below?)
// TODO:
assert(
userEncryptedSymmetricKey,
'document encrypted symmetric key must exist',
);
try {
setLoading(true);
const userEncryptedSymmetricKeyArrayBuffer = Buffer.from(
userEncryptedSymmetricKey,
'base64',
);
const symmetricKey = await decryptSymmetricKey(
userEncryptedSymmetricKeyArrayBuffer.buffer,
encryptionSettings!.userPrivateKey,
);
if (!cancelled) {
setSettings({ documentSymmetricKey: symmetricKey });
}
} catch (err) {
console.error(err);
//
// TODO: this should display a global error since if encryption needed it should able
// to retrieve information (except if onboarding needed, but still...)
//
// maybe this should be a return value so the parent knows where to set the CTA
//
if (!cancelled) {
setSettings(null);
}
} finally {
setLoading(false);
}
}
initDocumentEncryption();
return () => {
cancelled = true;
};
}, [encryptionLoading, encryptionSettings, userEncryptedSymmetricKey]);
return {
documentEncryptionLoading: loading,
documentEncryptionSettings: settings,
};
}
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react';
import { getEncryptionDB } from '../encryptionDB';
export function useEncryption(userId?: string): {
encryptionLoading: boolean;
encryptionSettings: {
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null;
} {
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState<{
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null>(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);
return;
} else if (enableEncryption === false) {
setLoading(false);
setSettings(null);
return;
}
try {
setLoading(true);
// We must first retrieve user keys locally
const encryptionDatabase = await getEncryptionDB();
const userPrivateKey = await encryptionDatabase.get(
'privateKey',
`user:${userId}`,
);
if (!userPrivateKey) {
throw new Error('user has no local private key (needs onboarding)');
}
const userPublicKey = await encryptionDatabase.get(
'publicKey',
`user:${userId}`,
);
if (!userPublicKey) {
throw new Error('user is missing his public key');
}
if (!cancelled) {
setSettings({
userId: userId,
userPrivateKey: userPrivateKey,
userPublicKey: userPublicKey,
});
}
} catch (err) {
console.error(err);
//
// TODO: this should display a global error since if encryption needed it should able
// to retrieve information (except if onboarding needed, but still...)
//
// maybe this should be a return value so the parent knows where to set the CTA
//
if (!cancelled) {
setSettings(null);
}
} finally {
setLoading(false);
}
}
initEncryption();
return () => {
cancelled = true;
};
}, [userId, enableEncryption]);
return { encryptionLoading: loading, encryptionSettings: settings };
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from 'react';
import {
STORE_KNOWN_PUBLIC_KEYS,
getEncryptionDB,
} from '../encryptionDB';
export interface PublicKeyMismatch {
userId: string;
knownKey: string;
currentKey: string;
}
/**
* 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.
*/
export function usePublicKeyRegistry(
accessesPublicKeysPerUser: Record<string, string> | undefined,
) {
const [mismatches, setMismatches] = useState<PublicKeyMismatch[]>([]);
const [loading, setLoading] = useState(true);
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!,
)) {
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 (err) {
console.error('usePublicKeyRegistry: failed to check keys', err);
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
setLoading(true);
checkKeys();
return () => {
cancelled = true;
};
}, [accessesPublicKeysPerUser]);
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));
},
[mismatches],
);
return {
mismatches,
hasMismatches: mismatches.length > 0,
loading,
acceptNewKey,
};
}
@@ -0,0 +1,12 @@
export {
decryptContent,
encryptContent,
generateSymmetricKey,
generateUserKeyPair,
prepareEncryptedSymmetricKeysForUsers,
encryptSymmetricKey,
} from './encryption';
export { getEncryptionDB } from './encryptionDB';
export { useDocumentEncryption } from './hook/useDocumentEncryption';
export { useEncryption } from './hook/useEncryption';
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
@@ -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,
};
}
}
@@ -78,7 +78,7 @@ describe('DocEditor', () => {
},
} as any;
const { rerender } = render(<DocEditor doc={doc} />, {
const { rerender } = render(<DocEditor doc={doc} encryptionSettings={null} 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 }} encryptionSettings={null} documentEncryptionSettings={null} />,
);
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
@@ -107,6 +107,8 @@ describe('DocEditor', () => {
id: 'test-doc-id-2',
computed_link_reach: LinkReach.RESTRICTED,
}}
encryptionSettings={null}
documentEncryptionSettings={null}
/>,
);
@@ -12,8 +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 { WebsocketProvider } from 'y-websocket';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -22,7 +20,11 @@ import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
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 {
@@ -78,11 +80,17 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
interface BlockNoteEditorProps {
doc: Doc;
// provider: HocuspocusProvider;
provider: WebsocketProvider;
provider: SwitchableProvider;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
}
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
export const BlockNoteEditor = ({
doc,
provider,
documentEncryptionSettings,
}: BlockNoteEditorProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
@@ -93,8 +101,13 @@ 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.doc, isConnectedToCollabServer);
useSaveDoc(
doc.id,
provider.document,
isConnectedToCollabServer,
doc.is_encrypted,
documentEncryptionSettings,
);
const { i18n } = useTranslation();
let lang = i18n.resolvedLanguage;
if (!lang || !(lang in locales)) {
@@ -123,8 +136,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
{
collaboration: {
provider: provider as { awareness?: Awareness | undefined },
// fragment: provider.document.getXmlFragment('document-store'),
fragment: provider.doc.getXmlFragment('document-store'),
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: cursorName,
color: randomColor(),
@@ -76,9 +76,21 @@ export const DocEditorContainer = ({
interface DocEditorProps {
doc: Doc;
encryptionSettings: {
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
}
export const DocEditor = ({ doc }: DocEditorProps) => {
export const DocEditor = ({
doc,
encryptionSettings,
documentEncryptionSettings,
}: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
@@ -122,7 +134,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
});
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
if (!isProviderReady || provider?.roomname !== doc.id) {
if (!isProviderReady || provider?.configuration.name !== doc.id) {
return <Loading />;
}
@@ -130,18 +142,27 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
<>
{isDesktop && <TableContent />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docHeader={
<DocHeader
doc={doc}
encryptionSettings={encryptionSettings}
documentEncryptionSettings={documentEncryptionSettings}
/>
}
docEditor={
readOnly ? (
<BlockNoteReader
// initialContent={provider.document.getXmlFragment(
// 'document-store',
// )}
initialContent={provider.doc.getXmlFragment('document-store')}
initialContent={provider.document.getXmlFragment(
'document-store',
)}
docId={doc.id}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
<BlockNoteEditor
doc={doc}
provider={provider}
documentEncryptionSettings={documentEncryptionSettings}
/>
)
}
isDeletedDoc={isDeletedDoc}
@@ -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,7 +1,8 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as Y from 'yjs';
import { encryptContent } from '@/docs/doc-collaboration/encryption';
import { useUpdateDoc } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { isFirefox } from '@/utils/userAgent';
@@ -14,6 +15,10 @@ export const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
isConnectedToCollabServer: boolean,
isEncrypted: boolean,
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
@@ -35,16 +40,6 @@ export const useSaveDoc = (
_updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
console.log(333333);
if (transaction.local) {
console.log('LOCAL');
} else {
console.log('REMOTE');
}
// console.log(transaction);
// transaction.
setIsLocalChange(transaction.local);
};
@@ -58,20 +53,42 @@ export const useSaveDoc = (
const saveDoc = useCallback(() => {
if (!isLocalChange) {
return false;
} else if (isEncrypted && !documentEncryptionSettings) {
// If the symmetric key is not yet ready we just ignore saving (either it needs onboarding or just a few seconds)
return false;
}
console.log('--------');
console.log(111111);
console.log(yDoc);
let state = Y.encodeStateAsUpdate(yDoc);
let contentPromise: Promise<typeof state>;
updateDoc({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
websocket: isConnectedToCollabServer,
if (isEncrypted) {
contentPromise = encryptContent(
new Uint8Array(state),
documentEncryptionSettings!.documentSymmetricKey,
);
} else {
contentPromise = Promise.resolve(state);
}
contentPromise.then((docState) => {
updateDoc({
id: docId,
content: toBase64(docState),
contentEncrypted: isEncrypted,
websocket: isConnectedToCollabServer,
});
});
return true;
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
}, [
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
isEncrypted,
documentEncryptionSettings,
]);
const router = useRouter();
@@ -37,7 +37,7 @@ describe('DocToolBox - Licence', () => {
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
render(<DocToolBox doc={doc as any} encryptionSettings={null} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText('Export the document');
@@ -55,7 +55,7 @@ describe('DocToolBox - Licence', () => {
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
render(<DocToolBox doc={doc as any} encryptionSettings={null} />, {
wrapper: AppWrapper,
});
@@ -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,
@@ -41,9 +43,21 @@ export const BoutonShare = ({
return null;
}
const warningIcon = hasKeyWarning ? (
<Icon
iconName="warning"
$color="var(--c--theme--colors--warning-600)"
$size="sm"
aria-label={t('Public key mismatch detected')}
/>
) : null;
if (hasAccesses) {
return (
<Box
$direction="row"
$align="center"
$gap="4px"
$css={css`
.c__button--medium {
height: var(--c--globals--spacings--lg);
@@ -52,6 +66,7 @@ export const BoutonShare = ({
}
`}
>
{warningIcon}
<Button
aria-label={t('Share button')}
variant="secondary"
@@ -74,14 +89,17 @@ export const BoutonShare = ({
}
return (
<Button
color="brand"
variant="tertiary"
onClick={open}
size="medium"
disabled={isDisabled}
>
{t('Share')}
</Button>
<Box $direction="row" $align="center" $gap="4px">
{warningIcon}
<Button
color="brand"
variant="tertiary"
onClick={open}
size="medium"
disabled={isDisabled}
>
{t('Share')}
</Button>
</Box>
);
};
@@ -20,9 +20,21 @@ import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
doc: Doc;
encryptionSettings: {
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null;
documentEncryptionSettings?: {
documentSymmetricKey: CryptoKey;
} | null;
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
export const DocHeader = ({
doc,
encryptionSettings,
documentEncryptionSettings,
}: DocHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
@@ -65,7 +77,13 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<DocHeaderInfo doc={doc} />
</Box>
</Box>
{!isDeletedDoc && <DocToolBox doc={doc} />}
{!isDeletedDoc && (
<DocToolBox
doc={doc}
encryptionSettings={encryptionSettings}
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,7 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { usePublicKeyRegistry } from '@/docs/doc-collaboration';
import { DocShareModal } from '@/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -44,9 +47,21 @@ const ModalExport = Export?.ModalExport;
interface DocToolBoxProps {
doc: Doc;
encryptionSettings: {
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null;
documentEncryptionSettings?: {
documentSymmetricKey: CryptoKey;
} | null;
}
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
export const DocToolBox = ({
doc,
encryptionSettings,
documentEncryptionSettings,
}: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const queryClient = useQueryClient();
@@ -57,9 +72,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
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(
doc.accesses_public_keys_per_user,
);
const { isSmallMobile, isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
@@ -125,6 +147,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 +238,19 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$margin={{ left: 'auto' }}
$gap={spacingsTokens['2xs']}
>
{doc.is_encrypted && (
<>
[chiffrement activé]
{/* TODO */}
</>
)}
<BoutonShare
doc={doc}
open={modalShare.open}
isHidden={isSmallMobile}
displayNbAccess={doc.abilities.accesses_view}
hasKeyWarning={hasKeyWarnings}
/>
{!isSmallMobile && ModalExport && (
@@ -217,6 +267,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
aria-label={t('Export the document')}
/>
)}
<DropdownMenu
options={options}
label={t('Open the document options')}
@@ -237,6 +288,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
<DocShareModal
onClose={() => modalShare.close()}
doc={doc}
documentEncryptionSettings={documentEncryptionSettings}
isRootDoc={treeContext?.root?.id === doc.id}
/>
)}
@@ -265,6 +317,31 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}}
/>
)}
{isModalEncryptOpen && (
<ModalEncryptDoc
doc={doc}
encryptionSettings={encryptionSettings}
onClose={() => setIsModalEncryptOpen(false)}
onSuccess={() => {
//
// TODO: probably it should make an hard refresh to get the setup
// but it should before register content in database with accesses, and broadcast the information through websocket
//
}}
/>
)}
{isModalRemoveEncryptionOpen && (
<ModalRemoveDocEncryption
doc={doc}
onClose={() => setIsModalRemoveEncryptionOpen(false)}
onSuccess={() => {
//
// TODO: probably it should make an hard refresh to get the setup
// but it should before register content in database with clean accesses, and broadcast the information through websocket
//
}}
/>
)}
{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';
@@ -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) {
@@ -17,6 +17,7 @@ export type DocsParams = {
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
is_encrypted?: boolean;
};
export const constructParams = (params: DocsParams): URLSearchParams => {
@@ -37,6 +38,9 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
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;
};
@@ -72,15 +72,18 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
const canSave =
variables.canSave &&
provider &&
// provider.document.guid === variables.docId;
provider.doc.guid === variables.docId;
provider.document.guid === variables.docId;
if (canSave) {
await updateDoc({
id: variables.docId,
// content: toBase64(Y.encodeStateAsUpdate(provider.document)),
content: toBase64(Y.encodeStateAsUpdate(provider.doc)),
});
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,78 @@
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>;
encryptedSymmetricKeyPerUser: Record<string, ArrayBuffer>;
}
export const encryptDoc = async ({
docId,
...params
}: EncryptDocProps): Promise<void> => {
const base64EncryptedSymmetricKeyPerUser: Record<string, string> = {};
for (const [userId, encryptedSymmetricKey] of Object.entries(
params.encryptedSymmetricKeyPerUser,
)) {
base64EncryptedSymmetricKeyPerUser[userId] = toBase64(
new Uint8Array(encryptedSymmetricKey),
);
}
const response = await fetchAPI(`documents/${docId}/encrypt/`, {
method: 'PATCH',
body: JSON.stringify({
...params,
content: toBase64(params.content),
encryptedSymmetricKeyPerUser: base64EncryptedSymmetricKeyPerUser,
}),
});
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);
}
},
});
};
@@ -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 RemoveDocEncryptionProps {
docId: string;
content: Uint8Array<ArrayBufferLike>;
}
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),
}),
});
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);
}
},
});
};
@@ -18,6 +18,7 @@ export type SubDocsParams = {
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
is_encrypted?: boolean;
parent_id: string;
};
@@ -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;
};
@@ -0,0 +1,138 @@
<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"
/>
<path
d="M17.9595 16.5L18.9325 17.473V18.2028H16.8648V20.5137L16.4999 20.8786L16.1351 20.5137V18.2028H14.0674V17.473L15.0404 16.5V13.8242H14.5539V13.0944H18.446V13.8242H17.9595V16.5Z"
fill="white"
/>
</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.7 KiB

@@ -0,0 +1,258 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { useUserUpdate } from '@/core/api/useUserUpdate';
import {
encryptContent,
generateSymmetricKey,
generateUserKeyPair,
getEncryptionDB,
prepareEncryptedSymmetricKeysForUsers,
} from '@/docs/doc-collaboration';
import { toBase64 } from '@/docs/doc-editor';
import { useAuth } from '@/features/auth';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useEncryptDoc,
useProviderStore,
} from '@/features/docs/doc-management';
import { useKeyboardAction } from '@/hooks';
interface ModalEncryptDocProps {
doc: Doc;
encryptionSettings: {
userId: string;
userPrivateKey: CryptoKey;
userPublicKey: CryptoKey;
} | null;
onClose: () => void;
onSuccess?: (doc: Doc) => void;
}
export const ModalEncryptDoc = ({
doc,
encryptionSettings,
onClose,
onSuccess,
}: ModalEncryptDocProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { provider } = useProviderStore();
const { user } = useAuth();
const { mutateAsync: updateUser } = useUserUpdate();
const {
mutate: encryptDoc,
isError,
error,
} = useEncryptDoc({
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
options: {
onSuccess: () => {
onSuccess && onSuccess(doc);
onClose();
toast(t('The document has been encrypted.'), VariantType.SUCCESS, {
duration: 4000,
});
},
},
});
const keyboardAction = useKeyboardAction();
const handleClose = () => {
onClose();
};
const handleEncrypt = async () => {
if (!provider || !user) {
return;
}
let currentUserPublicKeyFromThisOnboardingSession: ArrayBuffer | null =
null;
// Perform the onboarding if that's the first time using encryption on this device
if (!encryptionSettings) {
// TODO: trigger the onboarding, either by creating or retrieving a key from another device
// TODO: probably the logic should be at a device key level, not user one?
const userKeyPair = await generateUserKeyPair();
const encryptionDatabase = await getEncryptionDB();
// TODO: it should use transaction
// encryptionDatabase.transaction
await encryptionDatabase.put(
'privateKey',
userKeyPair.privateKey,
`user:${user.id}`,
);
await encryptionDatabase.put(
'publicKey',
userKeyPair.publicKey,
`user:${user.id}`,
);
const rawPublicKey = await crypto.subtle.exportKey(
'spki',
userKeyPair.publicKey,
);
// TODO: it should throw if the backend has already a public key (so the user can with concious forget the old one (but here he did the onboarding already so... it was probably a new device))
await updateUser({
id: user.id,
encryption_public_key: toBase64(new Uint8Array(rawPublicKey)),
});
currentUserPublicKeyFromThisOnboardingSession = rawPublicKey;
// TODO: should check encryptionSettings will update, otherwise hard refresh is needed
window.location.reload();
return;
}
const documentSymmetricKey = await generateSymmetricKey();
const state = Y.encodeStateAsUpdate(provider.document);
const encryptedContent = await encryptContent(
new Uint8Array(state),
documentSymmetricKey,
);
// Their public key are base64 encoded, decoding the whole
const usersPublicKeys: Record<string, ArrayBuffer> = {};
if (doc.accesses_public_keys_per_user) {
// TODO:
// TODO: should throw if missing public keys according to current accesses
// TODO:
for (const [userId, publicKey] of Object.entries(
doc.accesses_public_keys_per_user,
)) {
usersPublicKeys[userId] = Buffer.from(publicKey, 'base64').buffer;
}
// if the onboarding has been done directly in this encryption flow, the backend has not yet told the frontend
// about the current user key, so just patching the mapping with this new public key
if (currentUserPublicKeyFromThisOnboardingSession) {
usersPublicKeys[user.id] =
currentUserPublicKeyFromThisOnboardingSession;
}
} else {
// if it has been not provided it's weird because it should only happen for people not authenticated
throw new Error(`"accesses_public_keys_per_user" should be provided`);
}
// Prepare encrypted symmetric keys for all users with access
const encryptedSymmetricKeyPerUser =
await prepareEncryptedSymmetricKeysForUsers(
documentSymmetricKey,
usersPublicKeys,
);
// TODO:
// TODO: if none it should at least make it for the current user
// TODO: so it makes sense `accesses_public_keys_per_user` is always passed?
// TODO:
encryptDoc({
docId: doc.id,
content: encryptedContent,
encryptedSymmetricKeyPerUser,
});
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleEncryptKeyDown = keyboardAction(handleEncrypt);
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={handleClose}
aria-describedby="modal-encrypt-doc-title"
rightActions={
<>
<Button
variant="secondary"
fullWidth
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
>
{t('Cancel')}
</Button>
<Button
color="warning"
fullWidth
onClick={handleEncrypt}
onKeyDown={handleEncryptKeyDown}
>
{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}
/>
</Box>
}
>
<Box className="--docs--modal-encrypt-doc">
{!isError && (
<Text
$size="sm"
$variation="secondary"
$display="inline-block"
as="p"
>
<br />
TODO: warning about encryption
<br />
TODO: accesses for users without public key will be lost (list them)
<br />
TODO: if no public key for current user, provide an onboarding
<br />
TODO: if document public, tell it needs first to be private (add
backend check too)
</Text>
)}
{isError && <TextErrors causes={error.cause} />}
</Box>
</Modal>
);
};
@@ -0,0 +1,149 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useRemoveDocEncryption,
useProviderStore,
} from '@/features/docs/doc-management';
import { useKeyboardAction } from '@/hooks';
interface ModalRemoveDocEncryptionProps {
doc: Doc;
onClose: () => void;
onSuccess?: (doc: Doc) => void;
}
export const ModalRemoveDocEncryption = ({
doc,
onClose,
onSuccess,
}: ModalRemoveDocEncryptionProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { provider } = useProviderStore();
const {
mutate: removeDocEncryption,
isError,
error,
} = useRemoveDocEncryption({
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
options: {
onSuccess: () => {
onSuccess && onSuccess(doc);
onClose();
toast(
t('The document encryption has been removed.'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
},
},
});
const keyboardAction = useKeyboardAction();
const handleClose = () => {
onClose();
};
const handleRemoveEncryption = () => {
if (!provider) {
return;
}
const state = Y.encodeStateAsUpdate(provider.document);
removeDocEncryption({
docId: doc.id,
content: state,
});
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption);
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={handleClose}
aria-describedby="modal-remove-doc-encryption-title"
rightActions={
<>
<Button
variant="secondary"
fullWidth
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
>
{t('Cancel')}
</Button>
<Button
color="error"
fullWidth
onClick={handleRemoveEncryption}
onKeyDown={handleRemoveEncryptionKeyDown}
>
{t('Confirm')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text
$size="h6"
as="h1"
id="modal-remove-doc-encryption-title"
$margin="0"
$align="flex-start"
>
{t('Remove document encryption')}
</Text>
<ButtonCloseModal
aria-label={t('Close the encryption removal modal')}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
/>
</Box>
}
>
<Box className="--docs--modal-remove-doc-encryption">
{!isError && (
<Text
$size="sm"
$variation="secondary"
$display="inline-block"
as="p"
>
<br />
TODO: warning about removing encryption
</Text>
)}
{isError && <TextErrors causes={error.cause} />}
</Box>
</Modal>
);
};
@@ -7,6 +7,7 @@ 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();
@@ -67,6 +70,12 @@ export const SimpleDocItem = ({
data-testid="doc-pinned-icon"
color="var(--c--contextuals--content--semantic--info--tertiary)"
/>
) : isEncrypted ? (
<EncryptedDocumentIcon
aria-hidden="true"
data-testid="doc-encryption-icon"
color="var(--c--contextuals--content--semantic--info--tertiary)"
/>
) : isChild ? (
<ChildDocument
aria-hidden="true"
@@ -1,4 +1,6 @@
export * from './DocIcon';
export * from './DocPage403';
export * from './ModalEncryptDoc';
export * from './ModalRemoveDoc';
export * from './ModalRemoveDocEncryption';
export * from './SimpleDocItem';
@@ -1,25 +1,87 @@
import { useEffect } from 'react';
import { useCollaborationUrl } from '@/core/config';
import { decryptContent } from '@/docs/doc-collaboration/encryption';
import { Base64, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { useBroadcastStore } from '@/stores';
import { useProviderStore } from '../stores/useProviderStore';
import { Base64 } from '../types';
export const useCollaboration = (room?: string, initialContent?: Base64) => {
export const useCollaboration = (
room: string | undefined,
initialContent: Base64 | undefined,
isEncrypted: boolean | undefined,
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null,
) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { user } = useAuth();
const { provider, createProvider, destroyProvider } = useProviderStore();
useEffect(() => {
if (!room || !collaborationUrl || provider) {
if (
!room ||
!collaborationUrl ||
!user ||
isEncrypted === undefined ||
(isEncrypted === true && !documentEncryptionSettings) ||
provider
) {
// TODO: make sure the logout would invalide this provider, also a change of local keys (after import...)
return;
}
console.log(222);
// since that's initially binary it has been wrapped as base64 first
let initialDocState = initialContent
? Buffer.from(initialContent, 'base64')
: undefined;
const newProvider = createProvider(collaborationUrl, room, initialContent);
setBroadcastProvider(newProvider);
// if the document is marked as encrypted we need an extra decoding to retrieve the Yjs state
// note: we hack a bit due to decryption being async
let contentPromise: Promise<
[typeof initialDocState, CryptoKey | undefined]
>;
if (isEncrypted) {
contentPromise = (async () => {
if (!documentEncryptionSettings) {
throw new Error(
`"documentEncryptionSettings" must be filled since document is encrypted`,
);
}
if (initialDocState) {
return [
Buffer.from(
await decryptContent(
initialDocState,
documentEncryptionSettings.documentSymmetricKey,
),
),
documentEncryptionSettings.documentSymmetricKey,
];
} else {
return [
initialDocState,
documentEncryptionSettings.documentSymmetricKey,
];
}
})();
} else {
contentPromise = Promise.resolve([initialDocState, undefined]);
}
contentPromise.then(([initialDocState, symmetricKey]) => {
const newProvider = createProvider(
collaborationUrl,
room,
initialDocState,
symmetricKey,
);
setBroadcastProvider(newProvider);
});
}, [
provider,
collaborationUrl,
@@ -27,6 +89,9 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
initialContent,
createProvider,
setBroadcastProvider,
user,
isEncrypted,
documentEncryptionSettings,
]);
/**
@@ -30,6 +30,7 @@ export const useCreateChildDocTree = (parentId?: string) => {
createChildDoc({
parentId,
isEncrypted: false,
});
};
};
@@ -1,63 +0,0 @@
import { MessageType } from "@hocuspocus/provider";
import type { Decoder } from "lib0/decoding";
import {
createDecoder,
peekVarString,
readVarUint,
readVarUint8Array,
readVarString,
} from "lib0/decoding";
import type { Encoder } from "lib0/encoding";
import {
createEncoder,
writeVarUint,
writeVarUint8Array,
writeVarString,
length,
} from "lib0/encoding";
export class IncomingMessage {
data: any;
encoder: Encoder;
decoder: Decoder;
constructor(data: any) {
this.data = data;
this.encoder = createEncoder();
this.decoder = createDecoder(new Uint8Array(this.data));
}
peekVarString(): string {
return peekVarString(this.decoder);
}
readVarUint(): MessageType {
return readVarUint(this.decoder);
}
readVarString(): string {
return readVarString(this.decoder);
}
readVarUint8Array() {
return readVarUint8Array(this.decoder);
}
writeVarUint(type: MessageType) {
return writeVarUint(this.encoder, type);
}
writeVarString(string: string) {
return writeVarString(this.encoder, string);
}
writeVarUint8Array(data: Uint8Array) {
return writeVarUint8Array(this.encoder, data);
}
length() {
return length(this.encoder);
}
}
@@ -1,35 +1,22 @@
import { CloseEvent } from '@hocuspocus/common';
import {
ConstructableOutgoingMessage,
HocuspocusProvider,
MessageType,
OutgoingMessageArguments,
WebSocketStatus,
} from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
// import { MessageSender } from '@hocuspocus/provider/src/MessageSender';
// import {
// MessageSender
// } from '@hocuspocus/provider/default';
import { fromUint8Array, toUint8Array } from 'js-base64';
import * as decoding from 'lib0/decoding';
import type { Data, MessageEvent } from 'ws';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { Base64 } from '@/docs/doc-management';
import { IncomingMessage } from '@/docs/doc-management/stores/IncomingMessage';
import { createAdaptedEncryptedWebsocketClass } from '@/docs/doc-collaboration/encryptedWebsocket';
import { RelayProvider } from '@/docs/doc-collaboration/relayProvider';
export type SwitchableProvider = RelayProvider | HocuspocusProvider;
export interface UseCollaborationStore {
createProvider: (
providerUrl: string,
storeId: string,
initialDoc?: Base64,
) => WebsocketProvider;
// ) => HocuspocusProvider;
initialDocState?: Buffer<ArrayBuffer>,
symmetricKey?: CryptoKey,
) => SwitchableProvider;
destroyProvider: () => void;
// provider: HocuspocusProvider | undefined;
provider: WebsocketProvider | undefined;
provider: SwitchableProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
@@ -45,145 +32,136 @@ const defaultValues = {
hasLostConnection: false,
};
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
class CustomProvider extends WebsocketProvider {}
// class CustomProvider extends HocuspocusProvider {
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// send(
// message: ConstructableOutgoingMessage,
// args: Partial<OutgoingMessageArguments>,
// ) {
// // if (!this._isAttached) return;
// // const messageSender = new MessageSender(message, args);
// // this.emit('outgoingMessage', { message: messageSender.message });
// // messageSender.send(this.configuration.websocketProvider);
// console.log('-----');
// if (message.name === 'UpdateMessage') {
// console.log(8888);
// console.log(args.update);
// if (args.update) {
// console.log('.......');
// console.log(typeof args.update);
// console.log('.......');
// // const base64EncodedUpdateAsString = fromUint8Array(args.update);
// // const encoder = new TextEncoder();
// // const base64EncodedUpdateAsUint8Array = encoder.encode(
// // base64EncodedUpdateAsString,
// // );
// // args.update = base64EncodedUpdateAsUint8Array;
// const decodedUpdate = Y.decodeUpdate(args.update);
// for (const struct of decodedUpdate.structs) {
// if (struct instanceof Y.Item) {
// if (struct.content instanceof Y.ContentString) {
// console.log('----');
// console.log(struct.content.str);
// console.log(struct.content.getContent());
// console.log(struct.content.getRef());
// }
// // TODO: check for other Y.ContentXXXX...? Maybe it could be image binary or something else
// // ... is it enough to encrypt only this and not the whole "update"? So the server can read it if needed
// // console.log('----');
// // console.log(struct);
// }
// }
// // Y.write;
// // const doc = new Y.Doc();
// // Y.applyUpdate(doc, args.update);
// // const result = doc.toJSON();
// // const decoder = decoding.createDecoder(args.update);
// // const result = decoding.readVarString(decoder);
// // console.log(fromUint8Array(args.update));
// // console.log(result);
// }
// } else {
// // console.log(777777);
// // console.log(message.name);
// // console.log(args);
// }
// // const msg = new message();
// // msg.get(args);
// // msg.
// console.log('-----');
// super.send(message, args);
// }
// // onMessage(event: MessageEvent) {
// // // const message = new IncomingMessage(event.data);
// // // const documentName = message.readVarString();
// // // message.writeVarString(documentName);
// // // this.emit('message', { event, message: new IncomingMessage(event.data) });
// // // new MessageReceiver(message).apply(this, true);
// // const message = new IncomingMessage(event.data);
// // const type = message.readVarUint();
// // if (type === MessageType.Sync) {
// // console.warn('THOMAS');
// // const base64EncodedUpdateAsUint8Array = event.data as Uint8Array;
// // const decoder = new TextDecoder();
// // const base64EncodedUpdateAsString = decoder.decode(
// // base64EncodedUpdateAsUint8Array,
// // );
// // event.data = toUint8Array(base64EncodedUpdateAsString) as Data;
// // }
// // // this.emit('message', { event, message: new IncomingMessage(event.data) });
// // // new MessageReceiver(message).apply(this, true);
// // console.log('-----');
// // console.log(99999);
// // console.log(event);
// // super.onMessage(event);
// // }
// }
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDoc) => {
createProvider: (wsUrl, storeId, initialDocState, encryptionSymmetricKey) => {
const isEncrypted = !!encryptionSymmetricKey;
const doc = new Y.Doc({
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
if (initialDocState) {
Y.applyUpdate(doc, initialDocState);
}
//
// TODO: should implement features for authentication (listening on message with custom payload?)
// same for previous "onSynced"
//
let provider: SwitchableProvider;
const provider = new CustomProvider(wsUrl, storeId, doc);
if (isEncrypted) {
//
// TODO: should implement features for authentication (listening on message with custom payload?)
// same for previous "onSynced"
//
const AdaptedEncryptedWebSocket = createAdaptedEncryptedWebsocketClass({
encryptionKey: encryptionSymmetricKey,
decryptionKey: encryptionSymmetricKey,
});
provider = new RelayProvider(wsUrl, storeId, doc, {
WebSocketPolyfill: AdaptedEncryptedWebSocket,
// For simplicity we always use websocket server even if there is local tabs,
// otherwise the question would be do we need to encrypt also for local tabs through BroadcastChannel or not
disableBc: true,
});
provider.on('connection-close', (event) => {
if (event) {
if (event.wasClean) {
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
void provider.connect();
} else if (event.code === 1000) {
/**
* Handle the "Reset Connection" event from the server
* This is triggered when the server wants to reset the connection
* for clients in the room.
* A disconnect is made automatically but it takes time to be triggered,
* so we force the disconnection here.
*/
provider.disconnect();
}
}
});
provider.on('status', (event) => {
set((state) => {
const nextConnected = event.status === 'connected';
/**
* status === 'connected' does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we loose the connection
*/
const connected =
event.status !== 'connected'
? {
isConnected: false,
}
: undefined;
return {
...connected,
isReady: state.isReady || event.status === 'disconnected',
hasLostConnection:
state.isConnected && !nextConnected
? true
: state.hasLostConnection,
};
});
});
provider.on('sync', (state) => {
set({ isSynced: state, isReady: true });
});
} else {
provider = new HocuspocusProvider({
url: wsUrl,
name: storeId,
document: doc,
onDisconnect(data) {
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
provider.on('connection-close', (event) => {
if (event) {
if (event.wasClean) {
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
void provider.connect();
} else if (event.code === 1000) {
if ((data.event as ExtendedCloseEvent).wasClean) {
void provider.connect();
}
},
onAuthenticationFailed() {
set({ isReady: true, isConnected: false });
},
onAuthenticated() {
set({ isReady: true, isConnected: true });
},
onStatus: ({ status }) => {
set((state) => {
const nextConnected = status === WebSocketStatus.Connected;
/**
* status === WebSocketStatus.Connected does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we loose the connection
*/
const connected =
status !== WebSocketStatus.Connected
? {
isConnected: false,
}
: undefined;
return {
...connected,
isReady: state.isReady || status === WebSocketStatus.Disconnected,
hasLostConnection:
state.isConnected && !nextConnected
? true
: state.hasLostConnection,
};
});
},
onSynced: ({ state }) => {
set({ isSynced: state, isReady: true });
},
onClose(data) {
/**
* Handle the "Reset Connection" event from the server
* This is triggered when the server wants to reset the connection
@@ -191,110 +169,12 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
* A disconnect is made automatically but it takes time to be triggered,
* so we force the disconnection here.
*/
provider.disconnect();
}
}
});
provider.on('status', (event) => {
set((state) => {
const nextConnected = event.status === 'connected';
/**
* status === 'connected' does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we loose the connection
*/
const connected =
event.status !== 'connected'
? {
isConnected: false,
}
: undefined;
return {
...connected,
isReady: state.isReady || event.status === 'disconnected',
hasLostConnection:
state.isConnected && !nextConnected
? true
: state.hasLostConnection,
};
if (data.event.code === 1000) {
provider.disconnect();
}
},
});
});
provider.on('sync', (state) => {
set({ isSynced: state, isReady: true });
});
// const provider = new CustomProvider({
// url: wsUrl,
// name: storeId,
// document: doc,
// onDisconnect(data) {
// // Attempt to reconnect if the disconnection was clean (initiated by the client or server)
// if ((data.event as ExtendedCloseEvent).wasClean) {
// void provider.connect();
// }
// },
// onMessage(data) {
// console.log('-----');
// console.log(44444);
// console.log(data);
// },
// // onOutgoingMessage(data) {
// // console.log('-----');
// // console.log(555);
// // console.log(data);
// // },
// onAuthenticationFailed() {
// set({ isReady: true, isConnected: false });
// },
// onAuthenticated() {
// set({ isReady: true, isConnected: true });
// },
// onStatus: ({ status }) => {
// set((state) => {
// const nextConnected = status === WebSocketStatus.Connected;
// /**
// * status === WebSocketStatus.Connected does not mean we are totally connected
// * because authentication can still be in progress and failed
// * So we only update isConnected when we loose the connection
// */
// const connected =
// status !== WebSocketStatus.Connected
// ? {
// isConnected: false,
// }
// : undefined;
// return {
// ...connected,
// isReady: state.isReady || status === WebSocketStatus.Disconnected,
// hasLostConnection:
// state.isConnected && !nextConnected
// ? true
// : state.hasLostConnection,
// };
// });
// },
// onSynced: ({ state }) => {
// set({ isSynced: state, isReady: true });
// },
// onClose(data) {
// /**
// * Handle the "Reset Connection" event from the server
// * This is triggered when the server wants to reset the connection
// * for clients in the room.
// * A disconnect is made automatically but it takes time to be triggered,
// * so we force the disconnection here.
// */
// if (data.event.code === 1000) {
// provider.disconnect();
// }
// },
// });
}
set({
provider,
@@ -60,6 +60,7 @@ export interface Doc {
depth: number;
path: string;
is_favorite: boolean;
is_encrypted: boolean;
link_reach: LinkReach;
link_role?: LinkRole;
nb_accesses_direct: number;
@@ -71,6 +72,8 @@ export interface Doc {
numchild: number;
updated_at: string;
user_role: Role;
encrypted_document_symmetric_key_for_user?: string;
accesses_public_keys_per_user?: Record<string, string>;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
@@ -20,18 +20,21 @@ interface CreateDocAccessParams {
role: Role;
docId: Doc['id'];
memberId: User['id'];
memberEncryptedSymmetricKey: string | null;
}
export const createDocAccess = async ({
memberId,
role,
docId,
memberEncryptedSymmetricKey,
}: CreateDocAccessParams): Promise<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/`, {
method: 'POST',
body: JSON.stringify({
user_id: memberId,
role,
encrypted_document_symmetric_key_for_user: memberEncryptedSymmetricKey,
}),
});
@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { encryptSymmetricKey } from '@/docs/doc-collaboration';
import { toBase64 } from '@/docs/doc-editor';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
@@ -25,6 +27,9 @@ type APIErrorUser = APIError<{
type Props = {
doc: Doc;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
selectedUsers: User[];
onRemoveUser?: (user: User) => void;
onSubmit?: (selectedUsers: User[], role: Role) => void;
@@ -32,6 +37,7 @@ type Props = {
};
export const DocShareAddMemberList = ({
doc,
documentEncryptionSettings,
selectedUsers,
onRemoveUser,
afterInvite,
@@ -79,7 +85,8 @@ export const DocShareAddMemberList = ({
const onInvite = async () => {
setIsLoading(true);
const promises = selectedUsers.map((user) => {
const promises = selectedUsers.map(async (user) => {
const isInvitationMode = user.id === user.email;
const payload = {
@@ -87,15 +94,47 @@ export const DocShareAddMemberList = ({
docId: doc.id,
};
return isInvitationMode
? createInvitation({
...payload,
email: user.email.toLowerCase(),
})
: createDocAccess({
...payload,
memberId: user.id,
});
if (isInvitationMode) {
return createInvitation({
...payload,
email: user.email.toLowerCase(),
});
}
// For encrypted docs, encrypt the symmetric key with the user's public key
let memberEncryptedSymmetricKey: string | null = null;
if (
doc.is_encrypted &&
documentEncryptionSettings &&
user.encryption_public_key
) {
const publicKeyBuffer = Uint8Array.from(
atob(user.encryption_public_key),
(c) => c.charCodeAt(0),
).buffer;
const importedPublicKey = await crypto.subtle.importKey(
'spki',
publicKeyBuffer,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['encrypt'],
);
const encryptedKey = await encryptSymmetricKey(
documentEncryptionSettings.documentSymmetricKey,
importedPublicKey,
);
memberEncryptedSymmetricKey = toBase64(new Uint8Array(encryptedKey));
}
return createDocAccess({
...payload,
memberId: user.id,
memberEncryptedSymmetricKey,
});
});
const settledPromises = await Promise.allSettled(promises);
@@ -34,6 +34,7 @@ export const DocShareInvitationItem = ({
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
encryption_public_key: null,
language: 'en-us',
};
@@ -91,9 +92,11 @@ export const DocShareInvitationItem = ({
type DocShareModalInviteUserRowProps = {
user: User;
suffix?: string;
};
export const DocShareModalInviteUserRow = ({
user,
suffix,
}: DocShareModalInviteUserRowProps) => {
const { t } = useTranslation();
return (
@@ -104,6 +107,7 @@ export const DocShareModalInviteUserRow = ({
>
<SearchUserRow
user={user}
suffix={suffix}
right={
<BoxButton
className="right-hover"
@@ -21,11 +21,13 @@ type Props = {
doc?: Doc;
access: Access;
isInherited?: boolean;
suffix?: string;
};
export const DocShareMemberItem = ({
doc,
access,
isInherited = false,
suffix,
}: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
@@ -69,6 +71,7 @@ export const DocShareMemberItem = ({
<SearchUserRow
alwaysShowRight={true}
user={access.user}
suffix={suffix}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
@@ -93,10 +96,12 @@ export const DocShareMemberItem = ({
interface QuickSearchGroupMemberProps {
doc: Doc;
keyMismatchUserIds?: Set<string>;
}
export const QuickSearchGroupMember = ({
doc,
keyMismatchUserIds,
}: QuickSearchGroupMemberProps) => {
const { t } = useTranslation();
const membersQuery = useDocAccesses({
@@ -125,7 +130,15 @@ export const QuickSearchGroupMember = ({
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
<DocShareMemberItem
doc={doc}
access={access}
suffix={
keyMismatchUserIds?.has(access.user.id)
? t('DIFFERENT PUBLIC KEY')
: undefined
}
/>
)}
/>
</Box>
@@ -11,6 +11,7 @@ import {
QuickSearchData,
QuickSearchGroup,
} from '@/components/quick-search/';
import { usePublicKeyRegistry } from '@/docs/doc-collaboration';
import { Doc } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
@@ -49,16 +50,31 @@ const ShareModalStyle = createGlobalStyle`
type Props = {
doc: Doc;
documentEncryptionSettings?: {
documentSymmetricKey: CryptoKey;
} | null;
isRootDoc?: boolean;
onClose: () => void;
};
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
export const DocShareModal = ({
doc,
documentEncryptionSettings,
onClose,
isRootDoc = true,
}: Props) => {
const { t } = useTranslation();
const selectedUsersRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { isDesktop } = useResponsiveStore();
const { mismatches: keyMismatches } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
);
const keyMismatchUserIds = useMemo(
() => new Set(keyMismatches.map((m) => m.userId)),
[keyMismatches],
);
/**
* The modal content height is calculated based on the viewport height.
@@ -237,6 +253,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
<Box $padding={{ horizontal: 'base' }} $margin={{ top: '12x' }}>
<DocShareAddMemberList
doc={doc}
documentEncryptionSettings={documentEncryptionSettings ?? null}
selectedUsers={selectedUsers}
onRemoveUser={onRemoveUser}
afterInvite={() => {
@@ -301,7 +318,10 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
<Box $padding={{ horizontal: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
<QuickSearchGroupMember
doc={doc}
keyMismatchUserIds={keyMismatchUserIds}
/>
</Box>
)}
@@ -310,6 +330,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
isEncrypted={doc.is_encrypted}
/>
)}
</QuickSearch>
@@ -330,12 +351,14 @@ interface QuickSearchInviteInputSectionProps {
onSelect: (usr: User) => void;
searchUsersRawData: User[] | undefined;
userQuery: string;
isEncrypted: boolean;
}
const QuickSearchInviteInputSection = ({
onSelect,
searchUsersRawData,
userQuery,
isEncrypted,
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
@@ -347,6 +370,7 @@ const QuickSearchInviteInputSection = ({
full_name: '',
email: userQuery,
short_name: '',
encryption_public_key: null,
language: '',
};
@@ -377,7 +401,16 @@ const QuickSearchInviteInputSection = ({
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
renderElement={(user) => (
<DocShareModalInviteUserRow
user={user}
suffix={
isEncrypted && !user.encryption_public_key
? t('NO PUBLIC KEY')
: undefined
}
/>
)}
/>
</Box>
);
@@ -11,6 +11,7 @@ type Props = {
alwaysShowRight?: boolean;
right?: QuickSearchItemContentProps['right'];
isInvitation?: boolean;
suffix?: string;
};
export const SearchUserRow = ({
@@ -18,6 +19,7 @@ export const SearchUserRow = ({
right,
alwaysShowRight = false,
isInvitation = false,
suffix,
}: Props) => {
const hasFullName = !!user.full_name;
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -38,9 +40,16 @@ export const SearchUserRow = ({
background={isInvitation ? colorsTokens['gray-400'] : undefined}
/>
<Box $direction="column">
<Text $size="sm" $weight="500">
{hasFullName ? user.full_name : user.email}
</Text>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Text $size="sm" $weight="500">
{hasFullName ? user.full_name : user.email}
</Text>
{suffix && (
<Text $size="xs" $weight="600" $color={colorsTokens['warning-600']}>
{suffix}
</Text>
)}
</Box>
{hasFullName && (
<Text $size="xs" $margin={{ top: '-2px' }} $variation="secondary">
{user.email}
@@ -221,6 +221,7 @@ export const DocTreeItemActions = ({
createChildDoc({
parentId: doc.id,
isEncrypted: false,
});
}}
$theme="brand"
@@ -92,6 +92,7 @@ export const ModalConfirmationVersion = ({
updateDoc({
id: docId,
content: version.content,
contentEncrypted: false,
});
onClose();
@@ -74,6 +74,7 @@ export function useImportDoc(props?: UseImportDocOptions) {
is_creator_me: isCreatorMe,
title: undefined,
is_favorite: undefined,
is_encrypted: undefined,
},
],
},
@@ -10,6 +10,8 @@ import styled, { css } from 'styled-components';
import AllDocs from '@/assets/icons/doc-all.svg';
import { Box, Card, Icon, Text } from '@/components';
import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { useEncryption } from '@/features/docs/doc-collaboration';
import { useResponsiveStore } from '@/stores';
import { useInfiniteDocsTrashbin } from '../api';
@@ -79,7 +81,13 @@ export const DocsGrid = ({
});
}, [data?.pages]);
const loading = isFetching || isLoading;
const { user } = useAuth();
const { encryptionLoading, encryptionSettings } = useEncryption(user?.id);
// TODO:
// TODO: from here `encryptionSettings` should be used in case of adjusting accesses on a document
// TODO:
const loading = isFetching || isLoading || encryptionLoading;
const hasDocs = data?.pages.some((page) => page.results.length > 0);
const loadMore = (inView: boolean) => {
if (!inView || loading) {
@@ -96,7 +96,11 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
<SimpleDocItem
isPinned={doc.is_favorite}
isEncrypted={doc.is_encrypted}
doc={doc}
/>
{isShared && (
<Box
$padding={{ top: !isDesktop ? '4xs' : undefined }}
@@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin {
deleted_at: null,
depth: 1,
is_favorite: false,
is_encrypted: false,
nb_accesses_direct: 1,
nb_accesses_ancestors: 1,
numchild: 0,
@@ -24,17 +24,19 @@ export const Skeleton = ({ children }: PropsWithChildren) => {
const timeoutVisibleRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (isSkeletonVisible) {
setIsVisible(true);
setIsFadingOut(false);
} else {
setIsFadingOut(true);
if (!timeoutVisibleRef.current) {
timeoutVisibleRef.current = setTimeout(() => {
setIsVisible(false);
}, FADE_DURATION_MS * 2);
}
}
setIsVisible(false);
// if (isSkeletonVisible) {
// setIsVisible(true);
// setIsFadingOut(false);
// } else {
// setIsFadingOut(true);
// if (!timeoutVisibleRef.current) {
// timeoutVisibleRef.current = setTimeout(() => {
// setIsVisible(false);
// }, FADE_DURATION_MS * 2);
// }
// }
return () => {
if (timeoutVisibleRef.current) {
@@ -82,6 +82,7 @@
"Editor": "Embanner",
"Editor unavailable": "Aozer dihegerz",
"Emojify": "Emojifiañ",
"Encrypt document": "Criptografar o documento",
"Error during delete invitation": "Fazi en ur zilemel ar bedadenn",
"Error during update invitation": "Fazi e-pad hizivadur ar bedadenn",
"Error while deleting invitation": "Fazi e-pad ma oa o tilemel ur bedadenn",
@@ -170,6 +171,7 @@
"Reader": "Lenner",
"Reading": "Lenn hepken",
"Remove access": "Dilemel ar moned",
"Remove document encryption": "Remover a criptografia do documento",
"Rename": "Adenvel",
"Rephrase": "Adformulenniñ",
"Request access": "Goulenn mont e-barzh",
@@ -336,6 +338,7 @@
"Editor unavailable": "Editor nicht verfügbar",
"Embed a PDF file": "Eine PDF-Datei einbetten",
"Emojify": "Emojifizieren",
"Encrypt document": "Dokument verschlüsseln",
"Error during delete invitation": "Fehler beim Löschen der Einladung",
"Error during update invitation": "Fehler beim Aktualisieren der Einladung",
"Error while deleting invitation": "Fehler beim Löschen der Einladung",
@@ -433,6 +436,7 @@
"Reader": "Leser",
"Reading": "Lesen",
"Remove access": "Zugriff entziehen",
"Remove document encryption": "Dokumentverschlüsselung entfernen",
"Remove emoji": "Dokumenten-Symbol entfernen",
"Rename": "Umbenennen",
"Rephrase": "Umformulieren",
@@ -584,6 +588,7 @@
"Editor": "Editor",
"Editor unavailable": "Editor no disponible",
"Emojify": "Emojizar",
"Encrypt document": "Cifrar el documento",
"Error during delete invitation": "Error al eliminar la invitación",
"Error during update invitation": "Error al actualizar la invitación",
"Export": "Exportar",
@@ -643,6 +648,7 @@
"Quick search input": "Entrada de búsqueda rápida",
"Reader": "Lector",
"Reading": "Lectura",
"Remove document encryption": "Eliminar el cifrado del documento",
"Rename": "Cambiar el nombre",
"Rephrase": "Reformular",
"Request access": "Solicitar acceso",
@@ -799,6 +805,7 @@
"Editor unavailable": "Éditeur indisponible",
"Embed a PDF file": "Intégrer un fichier PDF",
"Emojify": "Emojifier",
"Encrypt document": "Chiffrer le document",
"Error during delete invitation": "Erreur lors de la suppression de l'invitation",
"Error during update invitation": "Erreur lors de la mise à jour de l'invitation",
"Error while deleting invitation": "Erreur lors de la suppression de l'invitation",
@@ -902,6 +909,7 @@
"Reading": "Lecture seule",
"Refresh page": "Actualiser la page",
"Remove access": "Supprimer l'accès",
"Remove document encryption": "Enlever le chiffrement du document",
"Remove emoji": "Supprimer les emojis",
"Remove {{name}} from the invite list": "Retirer {{name}} de la liste d'invitation",
"Rename": "Renommer",
@@ -1043,6 +1051,7 @@
"Editor": "Editor",
"Editor unavailable": "Editor non disponibile",
"Emojify": "Emojify",
"Encrypt document": "Crittografare il documento",
"Error during delete invitation": "Errore durante l'eliminazione dell'invito",
"Error during update invitation": "Errore durante l'aggiornamento dell'invito",
"Export": "Esporta",
@@ -1093,6 +1102,7 @@
"Public document": "Documento pubblico",
"Reader": "Lettore",
"Reading": "Leggendo",
"Remove document encryption": "Rimuovere la crittografia del documento",
"Rename": "Rinomina",
"Rephrase": "Riformula",
"Restore": "Ripristina",
@@ -1241,6 +1251,7 @@
"Editor unavailable": "Editor niet beschikbaar",
"Embed a PDF file": "PDF bestand invoegen",
"Emojify": "Maak met emoji's",
"Encrypt document": "Document versleutelen",
"Error during delete invitation": "Fout bij verwijderen uitnodiging",
"Error during update invitation": "Fout tijdens bijwerken uitnodiging",
"Error while deleting invitation": "Fout bij verwijderen uitnodiging",
@@ -1344,6 +1355,7 @@
"Reading": "Lezen",
"Refresh page": "Pagina vernieuwen",
"Remove access": "Toegang verwijderen",
"Remove document encryption": "Documentversleuteling verwijderen",
"Remove emoji": "Emoji verwijderen",
"Remove {{name}} from the invite list": "Verwijder {{name}} uit de uitnodigingslijst",
"Rename": "Hernoem",
@@ -1546,6 +1558,7 @@
"Editor unavailable": "Редактор недоступен",
"Embed a PDF file": "Вложить PDF файл",
"Emojify": "Сделать эмодзи",
"Encrypt document": "Зашифровать документ",
"Error during delete invitation": "Ошибка при удалении приглашения",
"Error during update invitation": "Ошибка при обновлении приглашения",
"Error while deleting invitation": "Ошибка в процессе удаления приглашения",
@@ -1649,6 +1662,7 @@
"Reading": "Чтение",
"Refresh page": "Обновить страницу",
"Remove access": "Отменить доступ",
"Remove document encryption": "Удалить шифрование документа",
"Remove emoji": "Убрать эмодзи",
"Remove {{name}} from the invite list": "Удалить {{name}} из списка приглашений",
"Rename": "Переименовать",
@@ -1807,6 +1821,7 @@
"Editor": "Editör",
"Editor unavailable": "Editör mevcut değil",
"Emojify": "Emojileştir",
"Encrypt document": "Belgeyi şifrele",
"Export": "Dışa Aktar",
"Failed to copy link": "Bağlantı kopyalanamadı",
"Format": "Format",
@@ -1827,6 +1842,7 @@
"Pin": "Sabitle",
"Please download it only if it comes from a trusted source.": "Lütfen yalnızca güvenilir bir kaynaktan geliyorsa indirin.",
"Public document": "Herkese açık belge",
"Remove document encryption": "Belge şifrelemesini kaldır",
"Rename": "Yeniden adlandır",
"Rephrase": "Yeniden yaz",
"Search": "Ara",
@@ -1952,6 +1968,7 @@
"Editor unavailable": "Редактор недоступний",
"Embed a PDF file": "Вкласти PDF-файл",
"Emojify": "Зробити емодзі",
"Encrypt document": "Зашифрувати документ",
"Error during delete invitation": "Під час видалення запрошення сталася помилка",
"Error during update invitation": "Помилка при оновленні запрошення",
"Error while deleting invitation": "Помилка при видаленні запрошення",
@@ -2055,6 +2072,7 @@
"Reading": "Читання",
"Refresh page": "Оновити сторінку",
"Remove access": "Вилучити доступ",
"Remove document encryption": "Видалити шифрування документа",
"Remove emoji": "Прибрати емодзі",
"Remove {{name}} from the invite list": "Видалити {{name}} зі списку запрошень",
"Rename": "Перейменувати",
@@ -2256,6 +2274,7 @@
"Editor unavailable": "編輯器無法使用",
"Embed a PDF file": "內嵌 PDF 檔案",
"Emojify": "加入表情符號",
"Encrypt document": "加密文档",
"Error during delete invitation": "刪除邀請時發生錯誤",
"Error during update invitation": "更新邀請時發生錯誤",
"Error while deleting invitation": "刪除邀請時發生錯誤",
@@ -2357,6 +2376,7 @@
"Reading": "僅限閱讀",
"Refresh page": "重新整理頁面",
"Remove access": "移除存取權",
"Remove document encryption": "移除文档加密",
"Remove emoji": "移除表情符號",
"Remove {{name}} from the invite list": "將 {{name}} 從邀請名單中移除",
"Rename": "重新命名",
@@ -19,6 +19,10 @@ import {
useTrans,
} from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
import {
useDocumentEncryption,
useEncryption,
} from '@/features/docs/doc-collaboration';
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts';
@@ -87,28 +91,54 @@ const DocPage = ({ id }: DocProps) => {
},
);
const { authenticated, user } = useAuth();
const [doc, setDoc] = useState<Doc>();
const { encryptionLoading, encryptionSettings } = useEncryption(user?.id);
const { documentEncryptionLoading, documentEncryptionSettings } =
useDocumentEncryption(
encryptionLoading,
encryptionSettings,
doc?.is_encrypted,
doc?.encrypted_document_symmetric_key_for_user,
);
const { setCurrentDoc } = useDocStore();
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { replace } = useRouter();
useCollaboration(doc?.id, doc?.content);
useCollaboration(
doc?.id,
doc?.content,
doc?.is_encrypted,
documentEncryptionSettings,
);
const { t } = useTranslation();
const { authenticated } = useAuth();
const { untitledDocument } = useTrans();
/**
* Show skeleton when loading a document
*/
useEffect(() => {
if (!doc && !isError && !isSkeletonVisible) {
if (
!doc &&
encryptionLoading &&
documentEncryptionLoading &&
!isError &&
!isSkeletonVisible
) {
setIsSkeletonVisible(true);
}
if (isError) {
setIsSkeletonVisible(false);
}
}, [doc, isError, isSkeletonVisible, setIsSkeletonVisible]);
}, [
doc,
encryptionLoading,
documentEncryptionLoading,
isError,
isSkeletonVisible,
setIsSkeletonVisible,
]);
/**
* Scroll to top when navigating to a new document
@@ -211,7 +241,7 @@ const DocPage = ({ id }: DocProps) => {
);
}
if (!doc) {
if (!doc || encryptionLoading || documentEncryptionLoading) {
return <Loading />;
}
@@ -227,7 +257,11 @@ const DocPage = ({ id }: DocProps) => {
key="title"
/>
</Head>
<DocEditor doc={doc} />
<DocEditor
doc={doc}
encryptionSettings={encryptionSettings}
documentEncryptionSettings={documentEncryptionSettings}
/>
</>
);
};
@@ -1,19 +1,16 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
import { SwitchableProvider } from '@/features/docs/doc-management/stores/useProviderStore';
interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
// getBroadcastProvider: () => HocuspocusProvider | undefined;
getBroadcastProvider: () => WebsocketProvider | undefined;
getBroadcastProvider: () => SwitchableProvider | undefined;
handleProviderSync: () => void;
// provider?: HocuspocusProvider;
provider?: WebsocketProvider;
// setBroadcastProvider: (provider: HocuspocusProvider) => void;
setBroadcastProvider: (provider: WebsocketProvider) => void;
provider?: SwitchableProvider;
setBroadcastProvider: (provider: SwitchableProvider) => void;
setTask: (
taskLabel: string,
task: Y.Array<string>,
@@ -67,8 +64,7 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
return;
}
// const task = provider.document.getArray<string>(taskLabel);
const task = provider.doc.getArray<string>(taskLabel);
const task = provider.document.getArray<string>(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => {
+7 -2
View File
@@ -13,6 +13,7 @@
]
},
"scripts": {
"postinstall": "patch-package",
"APP_IMPRESS": "yarn workspace app-impress",
"APP_E2E": "yarn workspace app-e2e",
"I18N": "yarn workspace packages-i18n",
@@ -28,7 +29,8 @@
"i18n:deploy": "yarn I18N run format-deploy && yarn APP_IMPRESS prettier",
"i18n:test": "yarn I18N run test",
"test": "yarn server:test && yarn app:test",
"server:test": "yarn COLLABORATION_SERVER run test"
"server:test": "yarn COLLABORATION_SERVER run test",
"patch-package": "patch-package"
},
"resolutions": {
"@tiptap/extensions": "3.14.0",
@@ -43,5 +45,8 @@
"wrap-ansi": "9.0.2",
"yjs": "13.6.29"
},
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"dependencies": {
"patch-package": "^8.0.1"
}
}
@@ -0,0 +1,69 @@
diff --git a/node_modules/y-websocket/dist/y-websocket.cjs b/node_modules/y-websocket/dist/y-websocket.cjs
index 8eeeff7..e260a83 100644
--- a/node_modules/y-websocket/dist/y-websocket.cjs
+++ b/node_modules/y-websocket/dist/y-websocket.cjs
@@ -209,13 +209,15 @@ const setupWS = (provider) => {
provider.wsconnected = false;
provider.synced = false;
- websocket.onmessage = (event) => {
+ // Cannot directly use `onmessage` as we need to wrap the listener with custom logic for encryption
+ websocket.addEventListener('message', (event) => {
provider.wsLastMessageReceived = time__namespace.getUnixTime();
const encoder = readMessage(provider, new Uint8Array(event.data), true);
if (encoding__namespace.length(encoder) > 1) {
websocket.send(encoding__namespace.toUint8Array(encoder));
}
- };
+ });
+
websocket.onerror = (event) => {
provider.emit('connection-error', [event, provider]);
};
@@ -430,9 +432,9 @@ class WebsocketProvider extends observable.ObservableV2 {
}
get url () {
- const encodedParams = url__namespace.encodeQueryParams(this.params);
- return this.serverUrl + '/' + this.roomname +
- (encodedParams.length === 0 ? '' : '?' + encodedParams)
+ // Patched: use the serverUrl as-is (like HocuspocusProvider) instead of
+ // appending roomname to the path. The room is already in the query string.
+ return this.serverUrl
}
/**
diff --git a/node_modules/y-websocket/src/y-websocket.js b/node_modules/y-websocket/src/y-websocket.js
index c0fc343..06dce00 100644
--- a/node_modules/y-websocket/src/y-websocket.js
+++ b/node_modules/y-websocket/src/y-websocket.js
@@ -180,13 +180,14 @@ const setupWS = (provider) => {
provider.wsconnected = false
provider.synced = false
- websocket.onmessage = (event) => {
+ // Cannot directly use `onmessage` as we need to wrap the listener with custom logic for encryption
+ websocket.addEventListener('message', (event) => {
provider.wsLastMessageReceived = time.getUnixTime()
const encoder = readMessage(provider, new Uint8Array(event.data), true)
if (encoding.length(encoder) > 1) {
websocket.send(encoding.toUint8Array(encoder))
}
- }
+ })
websocket.onerror = (event) => {
provider.emit('connection-error', [event, provider])
}
@@ -401,9 +402,9 @@ export class WebsocketProvider extends ObservableV2 {
}
get url () {
- const encodedParams = url.encodeQueryParams(this.params)
- return this.serverUrl + '/' + this.roomname +
- (encodedParams.length === 0 ? '' : '?' + encodedParams)
+ // Patched: use the serverUrl as-is (like HocuspocusProvider) instead of
+ // appending roomname to the path. The room is already in the query string.
+ return this.serverUrl
}
/**
@@ -21,14 +21,12 @@
"@sentry/node": "10.34.0",
"@sentry/profiling-node": "10.34.0",
"@tiptap/extensions": "*",
"@y/websocket-server": "^0.1.1",
"axios": "1.13.2",
"cors": "2.8.5",
"express": "5.2.1",
"express-ws": "5.0.2",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"y-websocket": "^3.0.0",
"yjs": "*"
},
"devDependencies": {
@@ -20,6 +20,7 @@ interface Doc {
content: Base64;
creator: string;
is_favorite: boolean;
is_encrypted: boolean;
link_reach: 'restricted' | 'public' | 'authenticated';
link_role: 'reader' | 'editor';
nb_accesses_ancestors: number;
@@ -2,7 +2,6 @@ import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { logger } from '@/utils';
import { closeConn, getYDoc } from '@/servers/standard/utils';
type ResetConnectionsRequestQuery = {
room?: string;
@@ -22,17 +21,19 @@ export const collaborationResetConnectionsHandler = (
return;
}
res.status(500).json({ error: 'not implemented yet' });
hocuspocusServer;
/**
* If no user ID is provided, close all connections in the room
*/
if (!userId) {
// hocuspocusServer.hocuspocus.closeConnections(room);
const doc = getYDoc(room);
if (doc) {
doc.conns.forEach((_, conn) => closeConn(doc, conn));
}
// const doc = getYDoc(room);
// if (doc) {
// doc.conns.forEach((_, conn) => closeConn(doc, conn));
// }
} else {
/**
* Close connections for the user in the room
@@ -48,17 +49,15 @@ export const collaborationResetConnectionsHandler = (
// }
// });
// });
const doc = getYDoc(room);
if (doc) {
doc.conns.forEach((clientIds, conn) => {
// TODO: with this current implementation there is no logic about user ID but only also "clientID"
// ... it should be adapted first as for hocuspocus before having this metadata
// closeConn(doc, conn)
});
}
// const doc = getYDoc(room);
// if (doc) {
// doc.conns.forEach((clientIds, conn) => {
// // TODO: with this current implementation there is no logic about user ID but only also "clientID"
// // ... it should be adapted first as for hocuspocus before having this metadata
// // closeConn(doc, conn)
// });
// }
}
res.status(200).json({ message: 'Connections reset' });
// res.status(200).json({ message: 'Connections reset' });
};
@@ -1,18 +1,112 @@
import { Request } from 'express';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import * as ws from 'ws';
import { fetchCurrentUser, fetchDocument } from '@/api/collaborationBackend';
import { hocuspocusServer } from '@/servers/hocuspocusServer';
import { setupWSConnection } from '@/servers/standard/utils'
import { logger } from '@/utils';
import { handleRelayServerConnection } from '@/servers/relayServer';
class WSProtocolError extends Error {
constructor(
public code: number,
message: string,
) {
super(message);
this.name = 'WSProtocolError';
}
}
export const collaborationWSHandler = async (
ws: ws.WebSocket,
req: Request,
) => {
// buffer messages that arrive while we do async checks below
// note: without this, the client's first Yjs sync message can arrive before handleConnection registers its listener,
// silently dropping it and causing the connection to hang forever
const earlyMessages: ws.RawData[] = [];
const earlyMessageHandler = (data: ws.RawData) => {
earlyMessages.push(data);
};
ws.on('message', earlyMessageHandler);
export const collaborationWSHandler = (ws: ws.WebSocket, req: Request) => {
try {
// hocuspocusServer.hocuspocus.handleConnection(ws, req);
const roomId = new URL(req.url, 'ws://x').searchParams.get('room');
setupWSConnection(ws, req, {
gc: true,
})
if (!roomId) {
throw new WSProtocolError(1007, 'room parameter must be provided');
} else if (!uuidValidate(roomId) || uuidVersion(roomId) !== 4) {
logger('Room name is not a valid uuid:', roomId);
throw new WSProtocolError(1008, 'unauthorized');
}
const document = await fetchDocument(roomId, req.headers);
if (!document.abilities.retrieve) {
logger('onConnect: Unauthorized to retrieve this document', roomId);
throw new WSProtocolError(1008, 'unauthorized');
}
const canEdit = document.abilities.update;
const session = req.headers['cookie']
?.split('; ')
.find((cookie) => cookie.startsWith('docs_sessionid='));
let sessionKey: string | null = null;
if (session) {
sessionKey = session.split('=')[1];
}
logger('Connection established on room:', roomId, 'canEdit:', canEdit);
/*
* Getting the user to retrieve more information
* but it's acceptable the request fails because non-encrypted files may be public
*/
let userId: string | null = null;
try {
const user = await fetchCurrentUser(req.headers);
userId = user.id;
} catch {
/* silent since optional */
}
// remove the early buffer before handing off to the real handler,
// which will register its own message listener synchronously
ws.off('message', earlyMessageHandler);
// Since for "end-to-end encryption" the server cannot maintains its own state for the document
// we use a different strategy with a relay server
if (document.is_encrypted) {
await handleRelayServerConnection(ws, roomId);
} else {
hocuspocusServer.hocuspocus.handleConnection(ws, req, {
roomId: roomId,
readOnly: !canEdit,
...(sessionKey ? { sessionKey: sessionKey } : {}),
...(userId ? { userId: userId } : {}),
});
}
// replay any messages the client sent while we were doing async checks
for (const msg of earlyMessages) {
ws.emit('message', msg);
}
} catch (error) {
console.error('Failed to handle WebSocket connection:', error);
ws.close();
if (error instanceof WSProtocolError) {
ws.close(error.code, error.message);
} else {
ws.close(1011, 'internal error');
}
} finally {
ws.off('message', earlyMessageHandler);
}
};
@@ -27,22 +27,27 @@ export const getDocumentConnectionInfoHandler = (
logger('Getting document connection info for room:', room);
const roomInfo = hocuspocusServer.hocuspocus.documents.get(room);
res.status(500).json({ error: 'not implemented yet' });
if (!roomInfo) {
logger('Room not found:', room);
res.status(404).json({ error: 'Room not found' });
return;
}
const connections = roomInfo
.getConnections()
.filter((connection) => connection.readOnly === false);
hocuspocusServer;
sessionKey;
res.status(200).json({
count: connections.length,
exists: connections.some(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(connection) => connection.context.sessionKey === sessionKey,
),
});
// const roomInfo = hocuspocusServer.hocuspocus.documents.get(room);
// if (!roomInfo) {
// logger('Room not found:', room);
// res.status(404).json({ error: 'Room not found' });
// return;
// }
// const connections = roomInfo
// .getConnections()
// .filter((connection) => connection.readOnly === false);
// res.status(200).json({
// count: connections.length,
// exists: connections.some(
// // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
// (connection) => connection.context.sessionKey === sessionKey,
// ),
// });
};
@@ -36,6 +36,9 @@ export const initApp = () => {
* - If a user ID is provided, close connections for the user in the room
*/
app.post(
//
// TODO: logic should work on connections for hopuspocus and standard one
//
routes.COLLABORATION_RESET_CONNECTIONS,
httpSecurity,
express.json(),
@@ -43,6 +46,9 @@ export const initApp = () => {
);
app.get(
//
// TODO: logic should work on connections for hopuspocus and standard one
//
routes.COLLABORATION_GET_CONNECTIONS,
httpSecurity,
getDocumentConnectionInfoHandler,
@@ -54,6 +60,9 @@ export const initApp = () => {
app.post(
//
// TODO: maybe since could be done on the frontend to avoid data going over the server?
// TODO: logic should work on connections for hopuspocus and standard one
//
// TODO: ... but this endpoint seems not used, to delete?
//
routes.CONVERT,
httpSecurity,
@@ -64,9 +73,9 @@ export const initApp = () => {
convertHandler,
);
//
// TODO: make sure Sentry is not saving sensitive info for e2ee
//
//
// TODO: make sure Sentry is not saving sensitive info for e2ee
//
Sentry.setupExpressErrorHandler(app);
app.get('/ping', (req, res) => {
@@ -1,7 +1,5 @@
import { Server } from '@hocuspocus/server';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { fetchCurrentUser, fetchDocument } from '@/api/collaborationBackend';
import { logger } from '@/utils';
export const hocuspocusServer = new Server({
@@ -9,19 +7,17 @@ export const hocuspocusServer = new Server({
timeout: 30000,
quiet: true,
async onConnect({
requestHeaders,
connectionConfig,
documentName,
requestParameters,
context,
request,
}) {
console.log(222222);
console.log('new CONNECTION');
// `documentName` is read from the initial message from within the Yjs protocol
// so just to avoid any risk we make sure comparing with explicit ID from the URL
const roomParam = requestParameters.get('room');
if (documentName !== roomParam) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!context.roomId || documentName !== context.roomId) {
logger(
'Invalid room name - Probable hacking attempt:',
documentName,
@@ -33,63 +29,25 @@ export const hocuspocusServer = new Server({
return Promise.reject(new Error('Wrong room name: Unauthorized'));
}
if (!uuidValidate(documentName) || uuidVersion(documentName) !== 4) {
logger('Room name is not a valid uuid:', documentName);
return Promise.reject(new Error('Wrong room name: Unauthorized'));
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (typeof context.readOnly !== 'boolean') {
return Promise.reject(
new Error(
'Wrong hocuspocus init: readOnly property should be set in the connection handler',
),
);
}
let canEdit = false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
connectionConfig.readOnly = context.readOnly;
try {
const document = await fetchDocument(documentName, requestHeaders);
if (!document.abilities.retrieve) {
logger(
'onConnect: Unauthorized to retrieve this document',
documentName,
);
return Promise.reject(new Error('Wrong abilities:Unauthorized'));
}
canEdit = document.abilities.update;
} catch (error: unknown) {
if (error instanceof Error) {
logger('onConnect: backend error', error.message);
}
return Promise.reject(new Error('Backend error: Unauthorized'));
}
connectionConfig.readOnly = !canEdit;
const session = requestHeaders['cookie']
?.split('; ')
.find((cookie) => cookie.startsWith('docs_sessionid='));
if (session) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.sessionKey = session.split('=')[1];
}
/*
* Unauthenticated users can be allowed to connect
* so we flag only authenticated users
*/
try {
const user = await fetchCurrentUser(requestHeaders);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.userId = user.id;
} catch {
/* empty */
}
logger(
'Connection established on room:',
documentName,
'canEdit:',
canEdit,
);
return Promise.resolve();
},
async beforeHandleMessage(data) {
//
// TODO: here or inside an equivalent listener "onMessage" to catch an event "ongoingEncryption"
// so we can close all connections properly and clear data. It needs to check this information from the backend first with "fetchDocument"
// this should be propagated to all subscribers so they can also prepare to refresh their page
//
},
});
@@ -0,0 +1,98 @@
import { Mutex } from 'async-mutex';
import * as ws from 'ws';
const rooms = new Map<string, Set<ws.WebSocket>>();
const roomsMutex = new Mutex();
function sendMessage(ws: ws.WebSocket, data: ws.RawData) {
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
ws.send(data, {}, (error) => {
if (error) {
ws.close();
}
});
} else {
ws.close();
}
}
export async function handleRelayServerConnection(
ws: ws.WebSocket,
roomId: string,
) {
ws.binaryType = 'arraybuffer'; // Same configuration in the client provider
const roomsMutexRelease = await roomsMutex.acquire();
let room = rooms.get(roomId);
try {
if (!room) {
const roomConnections = new Set<ws.WebSocket>();
rooms.set(roomId, roomConnections);
room = roomConnections;
}
} finally {
roomsMutexRelease();
}
ws.on('error', () => {
ws.close();
});
ws.on('message', (data) => {
if (data.toString() === 'ongoingDecryption') {
//
// TODO: here or inside an equivalent listener "onMessage" to catch an event "ongoingEncryption"
// so we can close all connections properly and clear data. It needs to check this information from the backend first with "fetchDocument"
// this should be propagated to all subscribers so they can also prepare to refresh their page
//
return;
}
// Relay blindly since this server is a passthrough due to encryption
for (const peer of Array.from(room)) {
if (peer !== ws) {
sendMessage(peer, data);
}
}
});
// Sending a ping signal, and expecting a response before the next iteration
let pongReceived = true;
ws.on('pong', () => {
pongReceived = true;
});
const pingInterval = setInterval(() => {
if (!pongReceived) {
ws.close();
} else {
pongReceived = false;
ws.ping();
}
}, 30 * 1000);
ws.on('close', async () => {
clearInterval(pingInterval);
ws.removeAllListeners();
const roomsMutexRelease = await roomsMutex.acquire();
try {
room.delete(ws);
if (room.size === 0) {
rooms.delete(roomId);
}
} finally {
roomsMutexRelease();
}
});
room.add(ws);
}
@@ -1,87 +0,0 @@
import http from 'http';
import * as number from 'lib0/number';
const CALLBACK_URL = process.env.CALLBACK_URL
? new URL(process.env.CALLBACK_URL)
: null;
const CALLBACK_TIMEOUT = number.parseInt(
process.env.CALLBACK_TIMEOUT || '5000',
);
const CALLBACK_OBJECTS = process.env.CALLBACK_OBJECTS
? JSON.parse(process.env.CALLBACK_OBJECTS)
: {};
export const isCallbackSet = !!CALLBACK_URL;
/**
* @param {import('./utils.js').WSSharedDoc} doc
*/
export const callbackHandler = (doc) => {
const room = doc.name;
const dataToSend = {
room,
data: {},
};
const sharedObjectList = Object.keys(CALLBACK_OBJECTS);
sharedObjectList.forEach((sharedObjectName) => {
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName];
dataToSend.data[sharedObjectName] = {
type: sharedObjectType,
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON(),
};
});
CALLBACK_URL && callbackRequest(CALLBACK_URL, CALLBACK_TIMEOUT, dataToSend);
};
/**
* @param {URL} url
* @param {number} timeout
* @param {Object} data
*/
const callbackRequest = (url, timeout, data) => {
data = JSON.stringify(data);
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
timeout,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
},
};
const req = http.request(options);
req.on('timeout', () => {
console.warn('Callback request timed out.');
req.abort();
});
req.on('error', (e) => {
console.error('Callback request error.', e);
req.abort();
});
req.write(data);
req.end();
};
/**
* @param {string} objName
* @param {string} objType
* @param {import('./utils.js').WSSharedDoc} doc
*/
const getContent = (objName, objType, doc) => {
switch (objType) {
case 'Array':
return doc.getArray(objName);
case 'Map':
return doc.getMap(objName);
case 'Text':
return doc.getText(objName);
case 'XmlFragment':
return doc.getXmlFragment(objName);
case 'XmlElement':
return doc.getXmlElement(objName);
default:
return {};
}
};
@@ -1,34 +0,0 @@
// import WebSocket from 'ws';
// import http from 'http';
// import * as number from 'lib0/number';
// import { setupWSConnection } from './utils.js';
// const wss = new WebSocket.Server({ noServer: true });
// const host = process.env.HOST || 'localhost';
// const port = number.parseInt(process.env.PORT || '1234');
// const server = http.createServer((_request, response) => {
// response.writeHead(200, { 'Content-Type': 'text/plain' });
// response.end('okay');
// });
// wss.on('connection', setupWSConnection);
// server.on('upgrade', (request, socket, head) => {
// // You may check auth of request here..
// // Call `wss.HandleUpgrade` *after* you checked whether the client has access
// // (e.g. by checking cookies, or url parameters).
// // See https://github.com/websockets/ws#client-authentication
// wss.handleUpgrade(
// request,
// socket,
// head,
// /** @param {any} ws */ (ws) => {
// wss.emit('connection', ws, request);
// },
// );
// });
// server.listen(port, host, () => {
// console.log(`running at '${host}' on port ${port}`);
// });
@@ -1,324 +0,0 @@
import * as Y from 'yjs';
import * as syncProtocol from '@y/protocols/sync';
import * as awarenessProtocol from '@y/protocols/awareness';
import * as encoding from 'lib0/encoding';
import * as decoding from 'lib0/decoding';
import * as map from 'lib0/map';
import * as eventloop from 'lib0/eventloop';
import { callbackHandler, isCallbackSet } from './callback.js';
const CALLBACK_DEBOUNCE_WAIT = parseInt(
process.env.CALLBACK_DEBOUNCE_WAIT || '2000',
);
const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(
process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000',
);
const debouncer = eventloop.createDebouncer(
CALLBACK_DEBOUNCE_WAIT,
CALLBACK_DEBOUNCE_MAXWAIT,
);
const wsReadyStateConnecting = 0;
const wsReadyStateOpen = 1;
const wsReadyStateClosing = 2; // eslint-disable-line
const wsReadyStateClosed = 3; // eslint-disable-line
// disable gc when using snapshots!
const gcEnabled = process.env.GC !== 'false' && process.env.GC !== '0';
// const persistenceDir = process.env.YPERSISTENCE
/**
* @type {{bindState: function(string,WSSharedDoc):void, writeState:function(string,WSSharedDoc):Promise<any>, provider: any}|null}
*/
let persistence = null;
/**
* @param {{bindState: function(string,WSSharedDoc):void,
* writeState:function(string,WSSharedDoc):Promise<any>,provider:any}|null} persistence_
*/
export const setPersistence = (persistence_) => {
persistence = persistence_;
};
/**
* @return {null|{bindState: function(string,WSSharedDoc):void,
* writeState:function(string,WSSharedDoc):Promise<any>}|null} used persistence layer
*/
export const getPersistence = () => persistence;
/**
* @type {Map<string,WSSharedDoc>}
*/
export const docs = new Map();
const messageSync = 0;
const messageAwareness = 1;
// const messageAuth = 2
/**
* @param {Uint8Array} update
* @param {any} _origin
* @param {WSSharedDoc} doc
* @param {any} _tr
*/
const updateHandler = (update, _origin, doc, _tr) => {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeUpdate(encoder, update);
const message = encoding.toUint8Array(encoder);
doc.conns.forEach((_, conn) => send(doc, conn, message));
};
/**
* @type {(ydoc: Y.Doc) => Promise<void>}
*/
let contentInitializor = (_ydoc) => Promise.resolve();
/**
* This function is called once every time a Yjs document is created. You can
* use it to pull data from an external source or initialize content.
*
* @param {(ydoc: Y.Doc) => Promise<void>} f
*/
export const setContentInitializor = (f) => {
contentInitializor = f;
};
export class WSSharedDoc extends Y.Doc {
/**
* @param {string} name
*/
constructor(name) {
super({ gc: gcEnabled });
this.name = name;
/**
* Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed
* @type {Map<Object, Set<number>>}
*/
this.conns = new Map();
/**
* @type {awarenessProtocol.Awareness}
*/
this.awareness = new awarenessProtocol.Awareness(this);
this.awareness.setLocalState(null);
/**
* @param {{ added: Array<number>, updated: Array<number>, removed: Array<number> }} changes
* @param {Object | null} conn Origin is the connection that made the change
*/
const awarenessChangeHandler = ({ added, updated, removed }, conn) => {
const changedClients = added.concat(updated, removed);
if (conn !== null) {
const connControlledIDs = /** @type {Set<number>} */ (
this.conns.get(conn)
);
if (connControlledIDs !== undefined) {
added.forEach((clientID) => {
connControlledIDs.add(clientID);
});
removed.forEach((clientID) => {
connControlledIDs.delete(clientID);
});
}
}
// broadcast awareness update
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageAwareness);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients),
);
const buff = encoding.toUint8Array(encoder);
this.conns.forEach((_, c) => {
send(this, c, buff);
});
};
this.awareness.on('update', awarenessChangeHandler);
this.on('update', /** @type {any} */ (updateHandler));
if (isCallbackSet) {
this.on('update', (_update, _origin, doc) => {
debouncer(() => callbackHandler(/** @type {WSSharedDoc} */ (doc)));
});
}
this.whenInitialized = contentInitializor(this);
}
}
/**
* Gets a Y.Doc by name, whether in memory or on disk
*
* @param {string} docname - the name of the Y.Doc to find or create
* @param {boolean} gc - whether to allow gc on the doc (applies only when created)
* @return {WSSharedDoc}
*/
export const getYDoc = (docname, gc = true) =>
map.setIfUndefined(docs, docname, () => {
const doc = new WSSharedDoc(docname);
doc.gc = gc;
if (persistence !== null) {
persistence.bindState(docname, doc);
}
docs.set(docname, doc);
return doc;
});
/**
* @param {any} conn
* @param {WSSharedDoc} doc
* @param {Uint8Array} message
*/
const messageListener = (conn, doc, message) => {
try {
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(message);
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case messageSync:
encoding.writeVarUint(encoder, messageSync);
syncProtocol.readSyncMessage(decoder, encoder, doc, conn);
// If the `encoder` only contains the type of reply message and no
// message, there is no need to send the message. When `encoder` only
// contains the type of reply, its length is 1.
if (encoding.length(encoder) > 1) {
send(doc, conn, encoding.toUint8Array(encoder));
}
break;
case messageAwareness: {
awarenessProtocol.applyAwarenessUpdate(
doc.awareness,
decoding.readVarUint8Array(decoder),
conn,
);
break;
}
}
} catch (err) {
console.error(err);
// @ts-ignore
doc.emit('error', [err]);
}
};
/**
* @param {WSSharedDoc} doc
* @param {any} conn
*/
export const closeConn = (doc, conn) => {
if (doc.conns.has(conn)) {
/**
* @type {Set<number>}
*/
// @ts-ignore
const controlledIds = doc.conns.get(conn);
doc.conns.delete(conn);
awarenessProtocol.removeAwarenessStates(
doc.awareness,
Array.from(controlledIds),
null,
);
if (doc.conns.size === 0 && persistence !== null) {
// if persisted, we store state and destroy ydocument
persistence.writeState(doc.name, doc).then(() => {
doc.destroy();
});
docs.delete(doc.name);
}
}
conn.close();
};
/**
* @param {WSSharedDoc} doc
* @param {import('ws').WebSocket} conn
* @param {Uint8Array} m
*/
const send = (doc, conn, m) => {
if (
conn.readyState !== wsReadyStateConnecting &&
conn.readyState !== wsReadyStateOpen
) {
closeConn(doc, conn);
}
try {
conn.send(m, {}, (err) => {
err != null && closeConn(doc, conn);
});
} catch (e) {
closeConn(doc, conn);
}
};
const pingTimeout = 30000;
/**
* @param {import('ws').WebSocket} conn
* @param {import('http').IncomingMessage} req
* @param {any} opts
*/
export const setupWSConnection = (
conn,
req,
{ docName = (req.url || '').slice(1).split('?')[0], gc = true } = {},
) => {
conn.binaryType = 'arraybuffer';
// get doc, initialize if it does not exist yet
const doc = getYDoc(docName, gc);
doc.conns.set(conn, new Set());
// listen and reply to events
conn.on(
'message',
/** @param {ArrayBuffer} message */ (message) =>
messageListener(conn, doc, new Uint8Array(message)),
);
// Check if connection is still alive
let pongReceived = true;
const pingInterval = setInterval(() => {
if (!pongReceived) {
if (doc.conns.has(conn)) {
closeConn(doc, conn);
}
clearInterval(pingInterval);
} else if (doc.conns.has(conn)) {
pongReceived = false;
try {
conn.ping();
} catch (e) {
closeConn(doc, conn);
clearInterval(pingInterval);
}
}
}, pingTimeout);
conn.on('close', () => {
closeConn(doc, conn);
clearInterval(pingInterval);
});
conn.on('pong', () => {
pongReceived = true;
});
// put the following in a variables in a block so the interval handlers don't keep in in
// scope
{
// send sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeSyncStep1(encoder, doc);
send(doc, conn, encoding.toUint8Array(encoder));
const awarenessStates = doc.awareness.getStates();
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageAwareness);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
doc.awareness,
Array.from(awarenessStates.keys()),
),
);
send(doc, conn, encoding.toUint8Array(encoder));
}
}
};
+108 -202
View File
@@ -7699,16 +7699,10 @@
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
"@y/websocket-server@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@y/websocket-server/-/websocket-server-0.1.1.tgz#861cbbbf85aa4e32b36514f8cd22619d3e3b8c5a"
integrity sha512-pPtXm5Ceqs4orhXXHwm2I+u1mKNBDNzlrwNiI7OMwM7PlVS4WCMpiIuSB8WsYeSuISbvpXPNvaj6H1MoQBbE+g==
dependencies:
lib0 "^0.2.102"
y-protocols "^1.0.5"
optionalDependencies:
ws "^6.2.1"
y-leveldb "^0.1.0"
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
"@zip.js/zip.js@^2.8.8":
version "2.8.10"
@@ -7720,28 +7714,6 @@ abs-svg-path@^0.1.1:
resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf"
integrity sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==
abstract-leveldown@^6.2.1:
version "6.3.0"
resolved "https://registry.yarnpkg.com/abstract-leveldown/-/abstract-leveldown-6.3.0.tgz#d25221d1e6612f820c35963ba4bd739928f6026a"
integrity sha512-TU5nlYgta8YrBMNpc9FwQzRbiXsj49gsALsXadbGHt9CROPzX5fB0rWDR5mtdpOOKa5XqRFpbj1QroPAoPzVjQ==
dependencies:
buffer "^5.5.0"
immediate "^3.2.3"
level-concat-iterator "~2.0.0"
level-supports "~1.0.0"
xtend "~4.0.0"
abstract-leveldown@~6.2.1, abstract-leveldown@~6.2.3:
version "6.2.3"
resolved "https://registry.yarnpkg.com/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz#036543d87e3710f2528e47040bc3261b77a9a8eb"
integrity sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==
dependencies:
buffer "^5.5.0"
immediate "^3.2.3"
level-concat-iterator "~2.0.0"
level-supports "~1.0.0"
xtend "~4.0.0"
accepts@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895"
@@ -8007,11 +7979,6 @@ async-function@^1.0.0:
resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b"
integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==
async-limiter@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
async-lock@^1.3.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
@@ -8363,14 +8330,6 @@ buffer-from@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer@^5.5.0, buffer@^5.6.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.1.13"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
@@ -8554,6 +8513,11 @@ chrome-trace-event@^1.0.2:
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"
integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==
ci-info@^3.7.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
ci-info@^4.2.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa"
@@ -9081,14 +9045,6 @@ [email protected], deepmerge@^4.2.2, deepmerge@^4.3.1:
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
deferred-leveldown@~5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz#27a997ad95408b61161aa69bd489b86c71b78058"
integrity sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==
dependencies:
abstract-leveldown "~6.2.1"
inherits "^2.0.3"
define-data-property@^1.0.1, define-data-property@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
@@ -9357,16 +9313,6 @@ encodeurl@^2.0.0:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
encoding-down@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/encoding-down/-/encoding-down-6.3.0.tgz#b1c4eb0e1728c146ecaef8e32963c549e76d082b"
integrity sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==
dependencies:
abstract-leveldown "^6.2.1"
inherits "^2.0.3"
level-codec "^9.0.0"
level-errors "^2.0.0"
encoding-sniffer@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz#396ec97ac22ce5a037ba44af1992ac9d46a7b819"
@@ -9408,13 +9354,6 @@ eol@^0.9.1:
resolved "https://registry.yarnpkg.com/eol/-/eol-0.9.1.tgz#f701912f504074be35c6117a5c4ade49cd547acd"
integrity sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==
errno@~0.1.1:
version "0.1.8"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f"
integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==
dependencies:
prr "~1.0.1"
error-causes@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/error-causes/-/error-causes-3.0.2.tgz#0d657671293d806f0b2008a4cf85518b762865c2"
@@ -10141,6 +10080,13 @@ find-up@^5.0.0:
locate-path "^6.0.0"
path-exists "^4.0.0"
find-yarn-workspace-root@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd"
integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==
dependencies:
micromatch "^4.0.2"
flat-cache@^6.1.19:
version "6.1.19"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-6.1.19.tgz#20e5b201c9b181a7b773b3b150108932077d2bbf"
@@ -10236,6 +10182,15 @@ fresh@^2.0.0:
resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4"
integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==
fs-extra@^10.0.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
fs-extra@^11.2.0:
version "11.3.2"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.2.tgz#c838aeddc6f4a8c74dd15f85e11fe5511bfe02a4"
@@ -10536,7 +10491,7 @@ gopd@^1.0.1, gopd@^1.2.0:
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.8:
graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.8:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
@@ -11013,7 +10968,7 @@ idb@^7.0.1:
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
ieee754@^1.1.13, ieee754@^1.2.1:
ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@@ -11038,11 +10993,6 @@ image-meta@^0.2.2:
resolved "https://registry.yarnpkg.com/image-meta/-/image-meta-0.2.2.tgz#a88dbdf1983d7c23a80c3e71d3b8acdb5379f5e0"
integrity sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==
immediate@^3.2.3:
version "3.3.0"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.3.0.tgz#1aef225517836bcdf7f2a2de2600c79ff0269266"
integrity sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==
immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
@@ -11224,6 +11174,11 @@ is-date-object@^1.0.5, is-date-object@^1.1.0:
call-bound "^1.0.2"
has-tostringtag "^1.0.2"
is-docker@^2.0.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -11420,6 +11375,13 @@ is-weakset@^2.0.3:
call-bound "^1.0.3"
get-intrinsic "^1.2.6"
is-wsl@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
dependencies:
is-docker "^2.0.0"
isarray@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
@@ -11884,11 +11846,6 @@ [email protected]:
import-local "^3.2.0"
jest-cli "30.2.0"
js-base64@^3.7.8:
version "3.7.8"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.8.tgz#af44496bc09fa178ed9c4adf67eb2b46f5c6d2a4"
integrity sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -11982,6 +11939,17 @@ json-schema@^0.4.0:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
json-stable-stringify@^1.0.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70"
integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==
dependencies:
call-bind "^1.0.8"
call-bound "^1.0.4"
isarray "^2.0.5"
jsonify "^0.0.1"
object-keys "^1.1.1"
json5@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
@@ -12010,6 +11978,11 @@ jsonfile@^6.0.1:
optionalDependencies:
graceful-fs "^4.1.6"
jsonify@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
jsonpointer@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559"
@@ -12047,6 +12020,13 @@ kind-of@^6.0.2:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
klaw-sync@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c"
integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==
dependencies:
graceful-fs "^4.1.11"
kleur@^4.1.4:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
@@ -12074,94 +12054,12 @@ lead@^4.0.0:
resolved "https://registry.yarnpkg.com/lead/-/lead-4.0.0.tgz#5317a49effb0e7ec3a0c8fb9c1b24fb716aab939"
integrity sha512-DpMa59o5uGUWWjruMp71e6knmwKU3jRBBn1kjuLWN9EeIOxNeSAwvHf03WIl8g/ZMR2oSQC9ej3yeLBwdDc/pg==
level-codec@^9.0.0:
version "9.0.2"
resolved "https://registry.yarnpkg.com/level-codec/-/level-codec-9.0.2.tgz#fd60df8c64786a80d44e63423096ffead63d8cbc"
integrity sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==
dependencies:
buffer "^5.6.0"
level-concat-iterator@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz#1d1009cf108340252cb38c51f9727311193e6263"
integrity sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==
level-errors@^2.0.0, level-errors@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/level-errors/-/level-errors-2.0.1.tgz#2132a677bf4e679ce029f517c2f17432800c05c8"
integrity sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==
dependencies:
errno "~0.1.1"
level-iterator-stream@~4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz#7ceba69b713b0d7e22fcc0d1f128ccdc8a24f79c"
integrity sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==
dependencies:
inherits "^2.0.4"
readable-stream "^3.4.0"
xtend "^4.0.2"
level-js@^5.0.0:
version "5.0.2"
resolved "https://registry.yarnpkg.com/level-js/-/level-js-5.0.2.tgz#5e280b8f93abd9ef3a305b13faf0b5397c969b55"
integrity sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==
dependencies:
abstract-leveldown "~6.2.3"
buffer "^5.5.0"
inherits "^2.0.3"
ltgt "^2.1.2"
level-packager@^5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/level-packager/-/level-packager-5.1.1.tgz#323ec842d6babe7336f70299c14df2e329c18939"
integrity sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==
dependencies:
encoding-down "^6.3.0"
levelup "^4.3.2"
level-supports@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/level-supports/-/level-supports-1.0.1.tgz#2f530a596834c7301622521988e2c36bb77d122d"
integrity sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==
dependencies:
xtend "^4.0.2"
level@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/level/-/level-6.0.1.tgz#dc34c5edb81846a6de5079eac15706334b0d7cd6"
integrity sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==
dependencies:
level-js "^5.0.0"
level-packager "^5.1.0"
leveldown "^5.4.0"
leveldown@^5.4.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/leveldown/-/leveldown-5.6.0.tgz#16ba937bb2991c6094e13ac5a6898ee66d3eee98"
integrity sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==
dependencies:
abstract-leveldown "~6.2.1"
napi-macros "~2.0.0"
node-gyp-build "~4.1.0"
levelup@^4.3.2:
version "4.4.0"
resolved "https://registry.yarnpkg.com/levelup/-/levelup-4.4.0.tgz#f89da3a228c38deb49c48f88a70fb71f01cafed6"
integrity sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==
dependencies:
deferred-leveldown "~5.3.0"
level-errors "~2.0.0"
level-iterator-stream "~4.0.0"
level-supports "~1.0.0"
xtend "~4.0.0"
leven@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
lib0@^0.2.102, lib0@^0.2.31, lib0@^0.2.99:
lib0@^0.2.102, lib0@^0.2.99:
version "0.2.117"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716"
integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==
@@ -12312,11 +12210,6 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
ltgt@^2.1.2:
version "2.2.1"
resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5"
integrity sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==
[email protected]:
version "3.7.2"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba"
@@ -12885,7 +12778,7 @@ micromark@^4.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromatch@^4.0.4, micromatch@^4.0.8:
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -13003,11 +12896,6 @@ nanoid@^5.1.3:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.6.tgz#30363f664797e7d40429f6c16946d6bd7a3f26c9"
integrity sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==
napi-macros@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b"
integrity sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==
napi-postinstall@^0.3.0:
version "0.3.4"
resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9"
@@ -13071,11 +12959,6 @@ [email protected], node-fetch@^2.6.7:
dependencies:
whatwg-url "^5.0.0"
node-gyp-build@~4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.1.1.tgz#d7270b5d86717068d114cc57fff352f96d745feb"
integrity sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -13251,6 +13134,14 @@ oniguruma-to-es@^4.3.4:
regex "^6.0.1"
regex-recursion "^6.0.2"
open@^7.4.2:
version "7.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
dependencies:
is-docker "^2.0.0"
is-wsl "^2.1.1"
orderedmap@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
@@ -13369,6 +13260,26 @@ parseurl@^1.3.3:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
patch-package@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60"
integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==
dependencies:
"@yarnpkg/lockfile" "^1.1.0"
chalk "^4.1.2"
ci-info "^3.7.0"
cross-spawn "^7.0.3"
find-yarn-workspace-root "^2.0.0"
fs-extra "^10.0.0"
json-stable-stringify "^1.0.2"
klaw-sync "^6.0.0"
minimist "^1.2.6"
open "^7.4.2"
semver "^7.5.3"
slash "^2.0.0"
tmp "^0.2.4"
yaml "^2.2.2"
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@@ -13884,11 +13795,6 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
integrity sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==
pstree.remy@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a"
@@ -15172,6 +15078,11 @@ simple-update-notifier@^2.0.0:
dependencies:
semver "^7.5.3"
slash@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -15884,6 +15795,11 @@ tldts@^7.0.5:
dependencies:
tldts-core "^7.0.17"
tmp@^0.2.4:
version "0.2.5"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8"
integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==
[email protected]:
version "1.0.5"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
@@ -17085,13 +17001,6 @@ [email protected]:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b"
integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==
ws@^6.2.1:
version "6.2.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee"
integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==
dependencies:
async-limiter "~1.0.0"
ws@^7.4.6:
version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
@@ -17124,19 +17033,11 @@ xmlchars@^2.2.0:
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
xtend@^4.0.0, xtend@^4.0.2, xtend@~4.0.0, xtend@~4.0.1:
xtend@^4.0.0, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y-leveldb@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/y-leveldb/-/y-leveldb-0.1.2.tgz#43f6c5004b6891b57926d8a1e0eb0c883003e34b"
integrity sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==
dependencies:
level "^6.0.1"
lib0 "^0.2.31"
y-prosemirror@^1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.3.7.tgz#f88e553da4ea33278b114cf0b6a0ea978b154e84"
@@ -17181,6 +17082,11 @@ yaml@^1.10.0:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.2.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5"
integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==
yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"