diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts index d4329669..3790de0d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts @@ -115,6 +115,11 @@ export class EncryptedWebSocket extends WebSocket { }); } + // allow sending raw message without encryption so they can be read + sendSystemMessage(message: string) { + super.send(message); + } + send(message: Uint8Array) { // 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 diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 241a249f..40166795 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -3,7 +3,7 @@ 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 { useUpdateDoc, useProviderStore } from '@/docs/doc-management/'; import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning'; import { isFirefox } from '@/utils/userAgent'; @@ -20,6 +20,7 @@ export const useSaveDoc = ( documentSymmetricKey: CryptoKey; } | null, ) => { + const { encryptionTransition } = useProviderStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalidQueries: [KEY_LIST_DOC_VERSIONS], onSuccess: () => { @@ -53,6 +54,8 @@ export const useSaveDoc = ( const saveDoc = useCallback(() => { if (!isLocalChange) { return false; + } else if (encryptionTransition) { + 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; @@ -82,6 +85,7 @@ export const useSaveDoc = ( return true; }, [ isLocalChange, + encryptionTransition, updateDoc, docId, yDoc, 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 1181d7ba..eb90993f 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 @@ -335,12 +335,6 @@ export const DocToolBox = ({ 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 && @@ -349,12 +343,6 @@ export const DocToolBox = ({ doc={doc} symmetricKey={documentEncryptionSettings.documentSymmetricKey} 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 && ( 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 fae97510..8c79a80d 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,6 +28,7 @@ import { toBase64 } from '@/docs/doc-editor'; import { useAuth } from '@/features/auth'; import { Doc, + EncryptionTransitionEvent, KEY_DOC, KEY_LIST_DOC, LinkReach, @@ -123,17 +124,13 @@ const encryptRemoteAttachments = async ( interface ModalEncryptDocProps { doc: Doc; onClose: () => void; - onSuccess?: (doc: Doc) => void; } -export const ModalEncryptDoc = ({ - doc, - onClose, - onSuccess, -}: ModalEncryptDocProps) => { +export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => { const { t } = useTranslation(); const { toast } = useToastProvider(); - const { provider } = useProviderStore(); + const { provider, notifyOthers, startEncryptionTransition } = + useProviderStore(); const { user } = useAuth(); const { encryptionSettings } = useUserEncryption(); const { mutateAsync: updateUser } = useUserUpdate(); @@ -235,6 +232,8 @@ export const ModalEncryptDoc = ({ return; } + notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED); + const documentSymmetricKey = await generateSymmetricKey(); // Their public key are base64 encoded, decoding the whole @@ -283,6 +282,10 @@ export const ModalEncryptDoc = ({ const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc); + // we have no need of patching back the current Yjs document with modifications + // since an encryption success will refetch data from the backend + ongoingDoc.destroy(); + const encryptedContent = await encryptContent( new Uint8Array(ongoingDocState), documentSymmetricKey, @@ -300,17 +303,21 @@ export const ModalEncryptDoc = ({ attachmentKeyMapping, }); - // since the encrypted state has been committed with success with can apply it locally with adjusted encrypted attachments - Y.applyUpdate(provider!.document, Y.encodeStateAsUpdate(ongoingDoc)); - - onSuccess?.(doc); - onClose(); - toast(t('The document has been encrypted.'), VariantType.SUCCESS, { duration: 4000, }); - ongoingDoc.destroy(); + // notify other users before destroying the provider since websocket connection needed + notifyOthers(EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED); + + // trigger the provider switch (hocuspocus → relay) + startEncryptionTransition('encrypting'); + + onClose(); + } catch (error) { + notifyOthers(EncryptionTransitionEvent.ENCRYPTION_CANCELED); + + throw error; } finally { setIsPending(false); } 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 63b17dd4..fa3f6c6c 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 @@ -16,6 +16,7 @@ import { decryptContent } from '@/docs/doc-collaboration'; import { createDocAttachment } from '@/docs/doc-editor/api'; import { Doc, + EncryptionTransitionEvent, KEY_DOC, KEY_LIST_DOC, extractAttachmentKeysAndMetadata, @@ -101,18 +102,17 @@ interface ModalRemoveDocEncryptionProps { doc: Doc; symmetricKey: CryptoKey; onClose: () => void; - onSuccess?: (doc: Doc) => void; } export const ModalRemoveDocEncryption = ({ doc, symmetricKey, onClose, - onSuccess, }: ModalRemoveDocEncryptionProps) => { const { t } = useTranslation(); const { toast } = useToastProvider(); - const { provider } = useProviderStore(); + const { provider, notifyOthers, startEncryptionTransition } = + useProviderStore(); const [isPending, setIsPending] = useState(false); @@ -141,6 +141,8 @@ export const ModalRemoveDocEncryption = ({ setIsPending(true); try { + notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED); + // clone the Yjs document since performing changes during decryption // that require backend confirmation const ongoingDoc = new Y.Doc(); @@ -155,36 +157,40 @@ export const ModalRemoveDocEncryption = ({ const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc); + // we have no need of patching back the current Yjs document with modifications + // since a removing encryption success will refetch data from the backend + ongoingDoc.destroy(); + 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, - }, + { duration: 4000 }, ); - ongoingDoc.destroy(); + // notify other users before destroying the provider since websocket connection needed + notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED); + + // trigger the provider switch (relay → hocuspocus): + startEncryptionTransition('removing-encryption'); + + onClose(); + } catch (error) { + notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED); + + throw error; } finally { setIsPending(false); } }; const handleCloseKeyDown = keyboardAction(handleClose); - const handleRemoveEncryptionKeyDown = keyboardAction( - handleRemoveEncryption, - ); + const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption); return ( { if ( @@ -26,7 +27,8 @@ export const useCollaboration = ( !user || isEncrypted === undefined || (isEncrypted === true && !documentEncryptionSettings) || - provider + provider || + encryptionTransition ) { // TODO: make sure the logout would invalide this provider, also a change of local keys (after import...) return; @@ -92,6 +94,7 @@ export const useCollaboration = ( user, isEncrypted, documentEncryptionSettings, + encryptionTransition, ]); /** diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx index 9b77988b..6e3eba87 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx @@ -3,11 +3,25 @@ import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider'; import * as Y from 'yjs'; import { create } from 'zustand'; -import { createAdaptedEncryptedWebsocketClass } from '@/docs/doc-collaboration/encryptedWebsocket'; +import { + EncryptedWebSocket, + createAdaptedEncryptedWebsocketClass, +} from '@/docs/doc-collaboration/encryptedWebsocket'; import { RelayProvider } from '@/docs/doc-collaboration/relayProvider'; +export enum EncryptionTransitionEvent { + ENCRYPTION_STARTED = 'system:encryption-started', + ENCRYPTION_SUCCEEDED = 'system:encryption-succeeded', + ENCRYPTION_CANCELED = 'system:encryption-canceled', + REMOVE_ENCRYPTION_STARTED = 'system:remove-encryption-started', + REMOVE_ENCRYPTION_SUCCEEDED = 'system:remove-encryption-succeeded', + REMOVE_ENCRYPTION_CANCELED = 'system:remove-encryption-canceled', +} + export type SwitchableProvider = RelayProvider | HocuspocusProvider; +export type EncryptionTransitionType = 'encrypting' | 'removing-encryption'; + export interface UseCollaborationStore { createProvider: ( providerUrl: string, @@ -16,11 +30,15 @@ export interface UseCollaborationStore { symmetricKey?: CryptoKey, ) => SwitchableProvider; destroyProvider: () => void; + notifyOthers: (event: EncryptionTransitionEvent) => void; + startEncryptionTransition: (type: EncryptionTransitionType) => void; + clearEncryptionTransition: () => void; provider: SwitchableProvider | undefined; isConnected: boolean; isReady: boolean; isSynced: boolean; hasLostConnection: boolean; + encryptionTransition: EncryptionTransitionType | null; resetLostConnection: () => void; } @@ -30,8 +48,34 @@ const defaultValues = { isReady: false, isSynced: false, hasLostConnection: false, + encryptionTransition: null, }; +function handleEncryptionSystemMessage( + message: string, + set: (partial: Partial) => void, + get: () => UseCollaborationStore, +) { + switch (message) { + case EncryptionTransitionEvent.ENCRYPTION_STARTED: + set({ encryptionTransition: 'encrypting' }); + break; + case EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED: + set({ encryptionTransition: 'removing-encryption' }); + break; + case EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED: + get().startEncryptionTransition('encrypting'); + break; + case EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED: + get().startEncryptionTransition('removing-encryption'); + break; + case EncryptionTransitionEvent.ENCRYPTION_CANCELED: + case EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED: + set({ encryptionTransition: null }); + break; + } +} + export const useProviderStore = create((set, get) => ({ ...defaultValues, createProvider: (wsUrl, storeId, initialDocState, encryptionSymmetricKey) => { @@ -59,6 +103,8 @@ export const useProviderStore = create((set, get) => ({ onSystemMessage: (message) => { if (message === 'system:authenticated') { set({ isReady: true, isConnected: true }); + } else { + handleEncryptionSystemMessage(message, set, get); } }, }); @@ -163,6 +209,9 @@ export const useProviderStore = create((set, get) => ({ }; }); }, + onStateless: ({ payload }) => { + handleEncryptionSystemMessage(payload, set, get); + }, onSynced: ({ state }) => { set({ isSynced: state, isReady: true }); }, @@ -187,6 +236,44 @@ export const useProviderStore = create((set, get) => ({ return provider; }, + startEncryptionTransition: (type: EncryptionTransitionType) => { + const provider = get().provider; + + // switching between hocuspocus and relay servers, we have to properly close the current one + if (provider) { + provider.destroy(); + } + + // set the right data so the page component has the indication it needs to fetch again document data + set({ + encryptionTransition: type, + provider: undefined, + isConnected: false, + isReady: false, + isSynced: false, + hasLostConnection: false, + }); + }, + clearEncryptionTransition: () => { + set({ encryptionTransition: null }); + }, + notifyOthers: (event: EncryptionTransitionEvent) => { + const provider = get().provider; + + if (!provider) { + return; + } + + if (provider instanceof HocuspocusProvider) { + provider.sendStateless(event); + } else if (provider instanceof RelayProvider) { + const ws = provider.ws as EncryptedWebSocket | null; + + if (ws) { + ws.sendSystemMessage(event); + } + } + }, destroyProvider: () => { const provider = get().provider; if (provider) { diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 6c9ec097..060c7673 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { Spinner } from '@gouvfr-lasuite/ui-kit'; import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components'; import { DEFAULT_QUERY_RETRY } from '@/core'; @@ -70,7 +71,13 @@ interface DocProps { } const DocPage = ({ id }: DocProps) => { - const { hasLostConnection, resetLostConnection } = useProviderStore(); + const { + hasLostConnection, + resetLostConnection, + encryptionTransition, + clearEncryptionTransition, + provider, + } = useProviderStore(); const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore(); const { data: docQuery, @@ -173,6 +180,39 @@ const DocPage = ({ id }: DocProps) => { } }, [hasLostConnection, doc?.id, queryClient, resetLostConnection]); + // when encryption transition destroys the provider, that's the signal to refetch the document + useEffect(() => { + if (encryptionTransition && !provider && doc?.id) { + void queryClient.invalidateQueries({ + queryKey: [KEY_DOC, { id: doc.id }], + }); + } + }, [encryptionTransition, provider, doc?.id, queryClient]); + + // clear transition state once the doc has been refetched with updated state + // and encryption settings are resolved (derived or cleared), unblocking useCollaboration() + useEffect(() => { + if (!encryptionTransition || provider) { + return; + } + + // this boolean check ensure the new document data has been properly fetch compared to the old data + const docUpdated = + encryptionTransition === 'encrypting' + ? doc?.is_encrypted === true + : doc?.is_encrypted === false; + + if (docUpdated && !documentEncryptionLoading) { + clearEncryptionTransition(); + } + }, [ + encryptionTransition, + provider, + doc?.is_encrypted, + documentEncryptionLoading, + clearEncryptionTransition, + ]); + useEffect(() => { if (!docQuery || isFetching) { return; @@ -329,9 +369,7 @@ const DocPage = ({ id }: DocProps) => { @@ -340,6 +378,25 @@ const DocPage = ({ id }: DocProps) => { ); } + if (encryptionTransition) { + return ( + + + + {encryptionTransition === 'encrypting' + ? t('Document encryption in progress, please wait...') + : t('Removing document encryption, please wait...')} + + + ); + } + return ( <> diff --git a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts index 50cf38a0..cdea68a8 100644 --- a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts @@ -43,11 +43,13 @@ export const hocuspocusServer = new Server({ 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 - // + async onStateless({ payload, document, connection }) { + // some interaction may require notifying other users so they adjust their UI, only let broadcast those events + if (payload.startsWith('system:')) { + // originator should manage it by himself + document.broadcastStateless(payload, (conn) => { + return conn !== connection; + }); + } }, }); diff --git a/src/frontend/servers/y-provider/src/servers/relayServer.ts b/src/frontend/servers/y-provider/src/servers/relayServer.ts index 25e02d91..8e9f66b8 100644 --- a/src/frontend/servers/y-provider/src/servers/relayServer.ts +++ b/src/frontend/servers/y-provider/src/servers/relayServer.ts @@ -21,10 +21,7 @@ export function getRelayRoom(roomId: string): Set | undefined { return rooms.get(roomId); } -export function closeRelayConnections( - roomId: string, - userId?: string, -): void { +export function closeRelayConnections(roomId: string, userId?: string): void { const room = rooms.get(roomId); if (!room) { @@ -76,16 +73,10 @@ export async function handleRelayServerConnection( }); 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 + // relay to all other peers in the room (passthrough due to encryption) + // it will also broadcast events about encryption transition, note that we don't close connections + // for this from the server because the clients could retrieve connecting immediately, it's better letting + // all clients reacting properly so they switch to the right provider for (const peer of Array.from(room)) { if (peer !== ws) { sendMessage(peer, data);