wip
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+99
@@ -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 };
|
||||
}
|
||||
+104
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
+22
-10
@@ -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}
|
||||
|
||||
+4
-4
@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('useSaveDoc', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('useSaveDoc', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('useSaveDoc', () => {
|
||||
const docId = 'test-doc-id';
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,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();
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+70
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+138
@@ -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 |
+258
@@ -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>
|
||||
);
|
||||
};
|
||||
+149
@@ -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>
|
||||
);
|
||||
};
|
||||
+9
@@ -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';
|
||||
|
||||
+73
-8
@@ -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,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
}
|
||||
+133
-253
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+49
-10
@@ -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);
|
||||
|
||||
+4
@@ -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"
|
||||
|
||||
+14
-1
@@ -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"
|
||||
|
||||
+1
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+17
-18
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user