wip decryption with attachments
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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={() => {
|
||||
//
|
||||
|
||||
+2
@@ -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 || {},
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+1
-72
@@ -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
|
||||
|
||||
+143
-24
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user