wip decryption with attachments

This commit is contained in:
Thomas Ramé
2026-03-03 18:21:31 +01:00
parent 3f8e105035
commit 834ed4226f
7 changed files with 249 additions and 97 deletions
+8
View File
@@ -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):
+23
View File
@@ -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)
@@ -330,9 +330,11 @@ export const DocToolBox = ({
}}
/>
)}
{isModalRemoveEncryptionOpen && (
{isModalRemoveEncryptionOpen &&
documentEncryptionSettings?.documentSymmetricKey && (
<ModalRemoveDocEncryption
doc={doc}
symmetricKey={documentEncryptionSettings.documentSymmetricKey}
onClose={() => setIsModalRemoveEncryptionOpen(false)}
onSuccess={() => {
//
@@ -10,6 +10,7 @@ import { toBase64 } from '@/features/docs/doc-editor';
interface RemoveDocEncryptionProps {
docId: string;
content: Uint8Array<ArrayBufferLike>;
attachmentKeyMapping?: Record<string, string>;
}
export const removeDocEncryption = async ({
@@ -21,6 +22,7 @@ export const removeDocEncryption = async ({
body: JSON.stringify({
...params,
content: toBase64(params.content),
attachmentKeyMapping: params.attachmentKeyMapping || {},
}),
});
@@ -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<string, ExtractedKeyMetadata> => {
const fragment = yDoc.getXmlFragment('document-store');
// for each key keep track of nodes
const keysAndMetadata = new Map<string, ExtractedKeyMetadata>();
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
@@ -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<Record<string, string>> => {
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
if (attachmentKeysAndMetadata.size === 0) {
return {};
}
const attachmentKeyMapping: Record<string, string> = {};
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
attachmentKeysAndMetadata.entries(),
)) {
const response = await fetch(oldAttachmentMetadata.mediaUrl, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('attachment cannot be fetched');
}
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 (
<Modal
isOpen
closeOnClickOutside
closeOnClickOutside={!isPending}
hideCloseButton
onClose={handleClose}
aria-describedby="modal-remove-doc-encryption-title"
@@ -91,6 +200,7 @@ export const ModalRemoveDocEncryption = ({
fullWidth
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
disabled={isPending}
>
{t('Cancel')}
</Button>
@@ -99,6 +209,14 @@ export const ModalRemoveDocEncryption = ({
fullWidth
onClick={handleRemoveEncryption}
onKeyDown={handleRemoveEncryptionKeyDown}
disabled={isPending}
icon={
isPending ? (
<div>
<Spinner size="sm" />
</div>
) : undefined
}
>
{t('Confirm')}
</Button>
@@ -125,6 +243,7 @@ export const ModalRemoveDocEncryption = ({
aria-label={t('Close the encryption removal modal')}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
disabled={isPending}
/>
</Box>
}
@@ -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<string, AttachmentKeyMetadata> => {
const fragment = yDoc.getXmlFragment('document-store');
const keysAndMetadata = new Map<string, AttachmentKeyMetadata>();
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();