From 834ed4226f472b2909c856eda2dc1f8a69355936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 3 Mar 2026 18:21:31 +0100 Subject: [PATCH] wip decryption with attachments --- src/backend/core/api/serializers.py | 8 + src/backend/core/api/viewsets.py | 23 +++ .../docs/doc-header/components/DocToolBox.tsx | 4 +- .../api/useRemoveDocEncryption.tsx | 2 + .../components/ModalEncryptDoc.tsx | 73 +------- .../components/ModalRemoveDocEncryption.tsx | 167 +++++++++++++++--- .../src/features/docs/doc-management/utils.ts | 69 ++++++++ 7 files changed, 249 insertions(+), 97 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 739d21a2..50a39022 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1002,6 +1002,14 @@ class RemoveEncryptionSerializer(serializers.Serializer): """ content = serializers.CharField(required=True) + attachmentKeyMapping = serializers.DictField( + child=serializers.CharField(), + required=False, + default=dict, + help_text="Mapping of old encrypted attachment key to new decrypted attachment key. " + "During decryption, encrypted attachments are re-uploaded decrypted under new keys. " + "This mapping tells the backend to remove the old keys and clean up.", + ) class ReactionSerializer(serializers.ModelSerializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1749eb17..0db70bc9 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2151,6 +2151,16 @@ class DocumentViewSet( serializer.is_valid(raise_exception=True) content = serializer.validated_data["content"] + attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {}) + + # Remove old encrypted attachment keys from the allowed list. + # The frontend uploaded decrypted copies under new keys and updated + # the Yjs content to reference them. + if attachment_key_mapping: + old_keys = set(attachment_key_mapping.keys()) + document.attachments = [ + k for k in (document.attachments or []) if k not in old_keys + ] # Update the document content and encryption status document.content = content # This will be cached and saved to object storage @@ -2162,6 +2172,19 @@ class DocumentViewSet( encrypted_document_symmetric_key_for_user=None ) + # Clean up old S3 objects only after the DB transaction has committed + if attachment_key_mapping: + def _cleanup_old_attachments(): + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + for old_key in attachment_key_mapping: + try: + s3_client.delete_object(Bucket=bucket_name, Key=old_key) + except ClientError: + logger.warning("Failed to delete old attachment %s", old_key) + + transaction.on_commit(_cleanup_old_attachments) + # Return the updated document serializer = self.get_serializer(document) return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 54b2079a..519fd382 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -330,9 +330,11 @@ export const DocToolBox = ({ }} /> )} - {isModalRemoveEncryptionOpen && ( + {isModalRemoveEncryptionOpen && + documentEncryptionSettings?.documentSymmetricKey && ( setIsModalRemoveEncryptionOpen(false)} onSuccess={() => { // diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx index 5d9a965c..911ab2dc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx @@ -10,6 +10,7 @@ import { toBase64 } from '@/features/docs/doc-editor'; interface RemoveDocEncryptionProps { docId: string; content: Uint8Array; + attachmentKeyMapping?: Record; } export const removeDocEncryption = async ({ @@ -21,6 +22,7 @@ export const removeDocEncryption = async ({ body: JSON.stringify({ ...params, content: toBase64(params.content), + attachmentKeyMapping: params.attachmentKeyMapping || {}, }), }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx index d2bf0bb1..e55d7298 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx @@ -28,84 +28,13 @@ import { Doc, KEY_DOC, KEY_LIST_DOC, + extractAttachmentKeysAndMetadata, useEncryptDoc, useProviderStore, } from '@/features/docs/doc-management'; import { useKeyboardAction } from '@/hooks'; import { Spinner } from '@gouvfr-lasuite/ui-kit'; -function traverseYDoc( - node: Y.XmlElement | Y.XmlFragment, - callback: (el: Y.XmlElement) => void, -) { - if (node instanceof Y.XmlElement) { - callback(node); - } - - node.toArray().forEach((child) => { - if (child instanceof Y.XmlElement || child instanceof Y.XmlFragment) { - traverseYDoc(child, callback); - } - }); -} - -const UUID = - '[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}'; -const ATTACHMENT_KEY_REGEX = new RegExp( - `^/media/(${UUID}/attachments/${UUID}(?:-unsafe)?\\.[a-zA-Z0-9]{1,10})$`, -); - -type ExtractedKeyMetadata = { - mediaUrl: string; - name?: string; - nodes: Y.XmlElement[]; -}; - -// extract unique attachment keys from the Yjs document -const extractAttachmentKeysAndMetadata = ( - yDoc: Y.Doc, -): Map => { - const fragment = yDoc.getXmlFragment('document-store'); - - // for each key keep track of nodes - const keysAndMetadata = new Map(); - - yDoc.transact(() => { - traverseYDoc(fragment, (node) => { - const urlAttributeValue = node.getAttribute('url'); - - if (urlAttributeValue) { - // url should always be valid - const url = new URL(urlAttributeValue); - - // applying the test only on the pathname since hostname can vary - const match = ATTACHMENT_KEY_REGEX.exec(url.pathname); - - if (match) { - const key = match[1]; - const keyMetadata = keysAndMetadata.get(key); - - if (keyMetadata) { - keyMetadata.nodes.push(node); - } else { - // avoid any unexpected parts - url.search = ''; - url.hash = ''; - - keysAndMetadata.set(key, { - mediaUrl: url.toString(), - name: node.getAttribute('name'), - nodes: [node], - }); - } - } - } - }); - }); - - return keysAndMetadata; -}; - /** * encrypt existing unencrypted attachments and return: * - a modified Yjs state with URLs pointing to new encrypted files diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx index 0ca44889..63b17dd4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx @@ -5,27 +5,108 @@ import { VariantType, useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; +import { Spinner } from '@gouvfr-lasuite/ui-kit'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; +import { backendUrl } from '@/api'; import { Box, ButtonCloseModal, Text, TextErrors } from '@/components'; +import { decryptContent } from '@/docs/doc-collaboration'; +import { createDocAttachment } from '@/docs/doc-editor/api'; import { Doc, KEY_DOC, KEY_LIST_DOC, + extractAttachmentKeysAndMetadata, useRemoveDocEncryption, useProviderStore, } from '@/features/docs/doc-management'; import { useKeyboardAction } from '@/hooks'; +/** + * Decrypt existing encrypted attachments and return: + * - a mapping of old S3 keys to new ones (for backend cleanup) + * + * The yDoc nodes are updated in place with the new URLs. + * Originals are never modified so if the process fails midway the document + * still works with its original encrypted attachments. + */ +const decryptRemoteAttachments = async ( + yDoc: Y.Doc, + docId: string, + symmetricKey: CryptoKey, +): Promise> => { + const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc); + + if (attachmentKeysAndMetadata.size === 0) { + return {}; + } + + const attachmentKeyMapping: Record = {}; + + for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from( + attachmentKeysAndMetadata.entries(), + )) { + const response = await fetch(oldAttachmentMetadata.mediaUrl, { + credentials: 'include', + }); + if (!response.ok) { + throw new Error('attachment cannot be fetched'); + } + + const encryptedBytes = new Uint8Array(await response.arrayBuffer()); + const decryptedBytes = await decryptContent(encryptedBytes, symmetricKey); + + const fileName = oldAttachmentMetadata.name ?? 'file'; + const decryptedFile = new File([decryptedBytes as BlobPart], fileName); + + const body = new FormData(); + body.append('file', decryptedFile); + + const result = await createDocAttachment({ docId, body }); + + const newKey = new URL( + result.file, + window.location.origin, + ).searchParams.get('key'); + + if (!newKey) { + throw new Error('file key must be provided once uploaded'); + } + + attachmentKeyMapping[oldAttachmentKey] = newKey; + } + + // once uploaded, update all nodes referencing attachments with their new key + yDoc.transact(() => { + for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from( + attachmentKeysAndMetadata.entries(), + )) { + const newMediaUrl = oldAttachmentMetadata.mediaUrl.replace( + oldAttachmentKey, + attachmentKeyMapping[oldAttachmentKey], + ); + + for (const node of oldAttachmentMetadata.nodes) { + node.setAttribute('url', newMediaUrl); + } + } + }); + + return attachmentKeyMapping; +}; + interface ModalRemoveDocEncryptionProps { doc: Doc; + symmetricKey: CryptoKey; onClose: () => void; onSuccess?: (doc: Doc) => void; } export const ModalRemoveDocEncryption = ({ doc, + symmetricKey, onClose, onSuccess, }: ModalRemoveDocEncryptionProps) => { @@ -33,54 +114,82 @@ export const ModalRemoveDocEncryption = ({ const { toast } = useToastProvider(); const { provider } = useProviderStore(); + const [isPending, setIsPending] = useState(false); + const { - mutate: removeDocEncryption, + mutateAsync: 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 = () => { + if (isPending) { + return; + } onClose(); }; - const handleRemoveEncryption = () => { - if (!provider) { + const handleRemoveEncryption = async () => { + if (!provider || isPending) { return; } - const state = Y.encodeStateAsUpdate(provider.document); + setIsPending(true); - removeDocEncryption({ - docId: doc.id, - content: state, - }); + try { + // clone the Yjs document since performing changes during decryption + // that require backend confirmation + const ongoingDoc = new Y.Doc(); + Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document)); + + // decrypt existing encrypted attachments + const attachmentKeyMapping = await decryptRemoteAttachments( + ongoingDoc, + doc.id, + symmetricKey, + ); + + const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc); + + await removeDocEncryption({ + docId: doc.id, + content: ongoingDocState, + attachmentKeyMapping, + }); + + // apply the URL changes from the cloned doc to the live document + Y.applyUpdate(provider!.document, Y.encodeStateAsUpdate(ongoingDoc)); + + onSuccess?.(doc); + onClose(); + + toast( + t('The document encryption has been removed.'), + VariantType.SUCCESS, + { + duration: 4000, + }, + ); + + ongoingDoc.destroy(); + } finally { + setIsPending(false); + } }; const handleCloseKeyDown = keyboardAction(handleClose); - const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption); + const handleRemoveEncryptionKeyDown = keyboardAction( + handleRemoveEncryption, + ); return ( {t('Cancel')} @@ -99,6 +209,14 @@ export const ModalRemoveDocEncryption = ({ fullWidth onClick={handleRemoveEncryption} onKeyDown={handleRemoveEncryptionKeyDown} + disabled={isPending} + icon={ + isPending ? ( +
+ +
+ ) : undefined + } > {t('Confirm')} @@ -125,6 +243,7 @@ export const ModalRemoveDocEncryption = ({ aria-label={t('Close the encryption removal modal')} onClick={handleClose} onKeyDown={handleCloseKeyDown} + disabled={isPending} /> } diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index c126cc8a..b4b3a679 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -3,6 +3,75 @@ import * as Y from 'yjs'; import { Doc, LinkReach } from './types'; +const UUID = + '[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}'; +const ATTACHMENT_KEY_REGEX = new RegExp( + `^/media/(${UUID}/attachments/${UUID}(?:-unsafe)?\\.[a-zA-Z0-9]{1,10})$`, +); + +export type AttachmentKeyMetadata = { + mediaUrl: string; + name?: string; + nodes: Y.XmlElement[]; +}; + +function traverseYDoc( + node: Y.XmlElement | Y.XmlFragment, + callback: (el: Y.XmlElement) => void, +) { + if (node instanceof Y.XmlElement) { + callback(node); + } + + node.toArray().forEach((child) => { + if (child instanceof Y.XmlElement || child instanceof Y.XmlFragment) { + traverseYDoc(child, callback); + } + }); +} + +/** + * Extract unique attachment S3 keys and their Yjs node references + * from the 'document-store' XmlFragment of a Y.Doc. + */ +export const extractAttachmentKeysAndMetadata = ( + yDoc: Y.Doc, +): Map => { + const fragment = yDoc.getXmlFragment('document-store'); + const keysAndMetadata = new Map(); + + yDoc.transact(() => { + traverseYDoc(fragment, (node) => { + const urlAttributeValue = node.getAttribute('url'); + + if (urlAttributeValue) { + const url = new URL(urlAttributeValue); + const match = ATTACHMENT_KEY_REGEX.exec(url.pathname); + + if (match) { + const key = match[1]; + const keyMetadata = keysAndMetadata.get(key); + + if (keyMetadata) { + keyMetadata.nodes.push(node); + } else { + url.search = ''; + url.hash = ''; + + keysAndMetadata.set(key, { + mediaUrl: url.toString(), + name: node.getAttribute('name'), + nodes: [node], + }); + } + } + } + }); + }); + + return keysAndMetadata; +}; + export const base64ToYDoc = (base64: string) => { const uint8Array = Buffer.from(base64, 'base64'); const ydoc = new Y.Doc();