diff --git a/.eslintignore b/.eslintignore index 41f60b65a..1a372e7d8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,30 +1,30 @@ landing/dist landing/flow-typed landing/node_modules lib/flow-typed lib/node_modules web/dist web/flow-typed web/node_modules web/opaque-ke-wasm/pkg -web/database/_generated +web/shared-worker/_generated web/backup-client-wasm/wasm/backup-client-wasm.js keyserver/app_compiled keyserver/landing_compiled keyserver/dist keyserver/secrets keyserver/facts keyserver/fonts keyserver/flow-typed keyserver/node_modules keyserver/src/landing keyserver/src/lib keyserver/src/web native/flow-typed native/node_modules native/codegen/dist native/android/app/build desktop/out desktop/dist services/electron-update-server/flow-typed target diff --git a/.gitignore b/.gitignore index eefcb6006..81d1d4c48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,57 +1,57 @@ .DS_Store node_modules landing/node_modules landing/dist lib/node_modules web/node_modules web/dist -web/database/sqlite -web/database/_generated/comm-query-executor.js.bak +web/shared-worker/sqlite +web/shared-worker/_generated/comm-query-executor.js.bak web/backup-client-wasm/wasm/backup-client-wasm_bg.wasm web/backup-client-wasm/wasm/backup-client-wasm.js web/backup-client-wasm/target keyserver/dist keyserver/node_modules keyserver/addons/rust-node-addon/target keyserver/addons/rust-node-addon/napi keyserver/certs keyserver/secrets keyserver/facts keyserver/*.env keyserver/*.env.* keyserver/cpu_profiling_logs services/tunnelbroker/target services/tunnelbroker/src/libcpp/test/build services/search-index-lambda/target .eslintcache .vscode !.vscode/extensions.json # CMake native/cpp/**/build services/*/build services/build services/lib/src/build shared/protos/build # Shared libraries shared/tunnelbroker-client/target shared/backup_client/target # Nix result* .direnv # Electron desktop/out/ desktop/assets/ desktop/dist/ desktop/*.provisionprofile diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js index 72e87a2ce..c3aa2109a 100644 --- a/web/account/account-hooks.js +++ b/web/account/account-hooks.js @@ -1,418 +1,418 @@ // @flow import olm from '@commapp/olm'; import invariant from 'invariant'; import localforage from 'localforage'; import * as React from 'react'; import uuid from 'uuid'; import { initialEncryptedMessageContent, getPrekeyValueFromBlob, } from 'lib/shared/crypto-utils.js'; import { OlmSessionCreatorContext } from 'lib/shared/olm-session-creator-context.js'; import type { SignedIdentityKeysBlob, CryptoStore, IdentityKeysBlob, CryptoStoreContextType, OLMIdentityKeys, NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import { type IdentityDeviceKeyUpload } from 'lib/types/identity-service-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; import { retrieveAccountKeysSet } from 'lib/utils/olm-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateCryptoKey, encryptData, exportKeyToJWK, } from '../crypto/aes-gcm-crypto-utils.js'; -import { isDesktopSafari } from '../database/utils/db-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { getOlmDataContentKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, } from '../push-notif/notif-crypto-utils.js'; import { setCryptoStore } from '../redux/crypto-store-reducer.js'; import { useSelector } from '../redux/redux-utils.js'; +import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; const CryptoStoreContext: React.Context = React.createContext(null); type Props = { +children: React.Node, }; function GetOrCreateCryptoStoreProvider(props: Props): React.Node { const dispatch = useDispatch(); const createCryptoStore = React.useCallback(async () => { await initOlm(); const identityAccount = new olm.Account(); identityAccount.create(); const { ed25519: identityED25519, curve25519: identityCurve25519 } = JSON.parse(identityAccount.identity_keys()); const identityAccountPicklingKey = uuid.v4(); const pickledIdentityAccount = identityAccount.pickle( identityAccountPicklingKey, ); const notificationAccount = new olm.Account(); notificationAccount.create(); const { ed25519: notificationED25519, curve25519: notificationCurve25519 } = JSON.parse(notificationAccount.identity_keys()); const notificationAccountPicklingKey = uuid.v4(); const pickledNotificationAccount = notificationAccount.pickle( notificationAccountPicklingKey, ); const newCryptoStore = { primaryAccount: { picklingKey: identityAccountPicklingKey, pickledAccount: pickledIdentityAccount, }, primaryIdentityKeys: { ed25519: identityED25519, curve25519: identityCurve25519, }, notificationAccount: { picklingKey: notificationAccountPicklingKey, pickledAccount: pickledNotificationAccount, }, notificationIdentityKeys: { ed25519: notificationED25519, curve25519: notificationCurve25519, }, }; dispatch({ type: setCryptoStore, payload: newCryptoStore }); return newCryptoStore; }, [dispatch]); const currentCryptoStore = useSelector(state => state.cryptoStore); const createCryptoStorePromiseRef = React.useRef>(null); const getCryptoStorePromise = React.useCallback(() => { if (currentCryptoStore) { return Promise.resolve(currentCryptoStore); } const currentCreateCryptoStorePromiseRef = createCryptoStorePromiseRef.current; if (currentCreateCryptoStorePromiseRef) { return currentCreateCryptoStorePromiseRef; } const newCreateCryptoStorePromise = (async () => { try { return await createCryptoStore(); } catch (e) { createCryptoStorePromiseRef.current = undefined; throw e; } })(); createCryptoStorePromiseRef.current = newCreateCryptoStorePromise; return newCreateCryptoStorePromise; }, [createCryptoStore, currentCryptoStore]); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { createCryptoStorePromiseRef.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ getInitializedCryptoStore: getCryptoStorePromise, }), [getCryptoStorePromise], ); return ( {props.children} ); } function useGetOrCreateCryptoStore(): () => Promise { const context = React.useContext(CryptoStoreContext); invariant(context, 'CryptoStoreContext not found'); return context.getInitializedCryptoStore; } function useGetSignedIdentityKeysBlob(): () => Promise { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); return React.useCallback(async () => { const [{ primaryAccount, primaryIdentityKeys, notificationIdentityKeys }] = await Promise.all([getOrCreateCryptoStore(), initOlm()]); const primaryOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( primaryAccount.picklingKey, primaryAccount.pickledAccount, ); const identityKeysBlob: IdentityKeysBlob = { primaryIdentityPublicKeys: primaryIdentityKeys, notificationIdentityPublicKeys: notificationIdentityKeys, }; const payloadToBeSigned: string = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: payloadToBeSigned, signature: primaryOLMAccount.sign(payloadToBeSigned), }; return signedIdentityKeysBlob; }, [getOrCreateCryptoStore]); } function useGetDeviceKeyUpload(): () => Promise { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); // `getSignedIdentityKeysBlob()` will initialize OLM, so no need to do it // again const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob(); const dispatch = useDispatch(); return React.useCallback(async () => { const [signedIdentityKeysBlob, cryptoStore] = await Promise.all([ getSignedIdentityKeysBlob(), getOrCreateCryptoStore(), ]); const primaryOLMAccount = new olm.Account(); const notificationOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( cryptoStore.primaryAccount.picklingKey, cryptoStore.primaryAccount.pickledAccount, ); notificationOLMAccount.unpickle( cryptoStore.notificationAccount.picklingKey, cryptoStore.notificationAccount.pickledAccount, ); const primaryAccountKeysSet = retrieveAccountKeysSet(primaryOLMAccount); const notificationAccountKeysSet = retrieveAccountKeysSet( notificationOLMAccount, ); const pickledPrimaryAccount = primaryOLMAccount.pickle( cryptoStore.primaryAccount.picklingKey, ); const pickledNotificationAccount = notificationOLMAccount.pickle( cryptoStore.notificationAccount.picklingKey, ); const updatedCryptoStore = { primaryAccount: { picklingKey: cryptoStore.primaryAccount.picklingKey, pickledAccount: pickledPrimaryAccount, }, primaryIdentityKeys: cryptoStore.primaryIdentityKeys, notificationAccount: { picklingKey: cryptoStore.notificationAccount.picklingKey, pickledAccount: pickledNotificationAccount, }, notificationIdentityKeys: cryptoStore.notificationIdentityKeys, }; dispatch({ type: setCryptoStore, payload: updatedCryptoStore }); return { keyPayload: signedIdentityKeysBlob.payload, keyPayloadSignature: signedIdentityKeysBlob.signature, contentPrekey: primaryAccountKeysSet.prekey, contentPrekeySignature: primaryAccountKeysSet.prekeySignature, notifPrekey: notificationAccountKeysSet.prekey, notifPrekeySignature: notificationAccountKeysSet.prekeySignature, contentOneTimeKeys: primaryAccountKeysSet.oneTimeKeys, notifOneTimeKeys: notificationAccountKeysSet.oneTimeKeys, }; }, [dispatch, getOrCreateCryptoStore, getSignedIdentityKeysBlob]); } function OlmSessionCreatorProvider(props: Props): React.Node { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); const currentCryptoStore = useSelector(state => state.cryptoStore); const createNewNotificationsSession = React.useCallback( async ( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ) => { const [{ notificationAccount }, encryptionKey] = await Promise.all([ getOrCreateCryptoStore(), generateCryptoKey({ extractable: isDesktopSafari }), initOlm(), ]); const account = new olm.Account(); const { picklingKey, pickledAccount } = notificationAccount; account.unpickle(picklingKey, pickledAccount); const notificationsPrekey = getPrekeyValueFromBlob( notificationsInitializationInfo.prekey, ); const session = new olm.Session(); session.create_outbound( account, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, notificationsInitializationInfo.oneTimeKey, ); const { body: initialNotificationsEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); const mainSession = session.pickle(picklingKey); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), picklingKey, }; const encryptedOlmData = await encryptData( new TextEncoder().encode(JSON.stringify(notificationsOlmData)), encryptionKey, ); const notifsOlmDataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForCookie(cookie); const notifsOlmDataContentKey = getOlmDataContentKeyForCookie( cookie, keyserverID, ); const persistEncryptionKeyPromise = (async () => { let cryptoKeyPersistentForm; if (isDesktopSafari) { // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON cryptoKeyPersistentForm = await exportKeyToJWK(encryptionKey); } else { cryptoKeyPersistentForm = encryptionKey; } await localforage.setItem( notifsOlmDataEncryptionKeyDBLabel, cryptoKeyPersistentForm, ); })(); await Promise.all([ localforage.setItem(notifsOlmDataContentKey, encryptedOlmData), persistEncryptionKeyPromise, ]); return initialNotificationsEncryptedMessage; }, [getOrCreateCryptoStore], ); const createNewContentSession = React.useCallback( async ( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, ) => { const [{ primaryAccount }] = await Promise.all([ getOrCreateCryptoStore(), initOlm(), ]); const account = new olm.Account(); const { picklingKey, pickledAccount } = primaryAccount; account.unpickle(picklingKey, pickledAccount); const contentPrekey = getPrekeyValueFromBlob( contentInitializationInfo.prekey, ); const session = new olm.Session(); session.create_outbound( account, contentIdentityKeys.curve25519, contentIdentityKeys.ed25519, contentPrekey, contentInitializationInfo.prekeySignature, contentInitializationInfo.oneTimeKey, ); const { body: initialContentEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); return initialContentEncryptedMessage; }, [getOrCreateCryptoStore], ); const notificationsSessionPromise = React.useRef>(null); const createNotificationsSession = React.useCallback( async ( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ) => { if (notificationsSessionPromise.current) { return notificationsSessionPromise.current; } const newNotificationsSessionPromise = (async () => { try { return await createNewNotificationsSession( cookie, notificationsIdentityKeys, notificationsInitializationInfo, keyserverID, ); } catch (e) { notificationsSessionPromise.current = undefined; throw e; } })(); notificationsSessionPromise.current = newNotificationsSessionPromise; return newNotificationsSessionPromise; }, [createNewNotificationsSession], ); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { notificationsSessionPromise.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ notificationsSessionCreator: createNotificationsSession, contentSessionCreator: createNewContentSession, }), [createNewContentSession, createNotificationsSession], ); return ( {props.children} ); } export { useGetSignedIdentityKeysBlob, useGetOrCreateCryptoStore, OlmSessionCreatorProvider, GetOrCreateCryptoStoreProvider, useGetDeviceKeyUpload, }; diff --git a/web/app.react.js b/web/app.react.js index e621a496d..8ccb4ff40 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,565 +1,565 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { WagmiConfig } from 'wagmi'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; import { getConfig, registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js'; import QrCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { MemberListSidebarProvider } from './chat/member-list-sidebar/member-list-sidebar-provider.react.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { olmAPI } from './crypto/olm-api.js'; import { initOpaque } from './crypto/opaque-utils.js'; -import { getDatabaseModule } from './database/database-module-provider.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { KeyserverReachabilityHandler } from './redux/keyserver-reachability-handler.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import { createTunnelbrokerInitMessage } from './selectors/tunnelbroker-selectors.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; +import { getCommSharedWorker } from './shared-worker/shared-worker-provider.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { TooltipProvider } from './tooltips/tooltip-provider.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; import { useWebLock, TUNNELBROKER_LOCK_NAME } from './web-lock.js'; void initOpaque(); // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; const desktopDetails = electron?.version ? { majorDesktopVersion: extractMajorDesktopVersion(electron?.version) } : null; registerConfig({ // We can't securely cache credentials on web resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 74, stateVersion: persistConfig.version, ...desktopDetails, }, authoritativeKeyserverID, olmAPI, }); const versionBroadcast = new BroadcastChannel('comm_version'); versionBroadcast.postMessage(getConfig().platformDetails.codeVersion); versionBroadcast.onmessage = (event: MessageEvent) => { if (event.data && event.data !== getConfig().platformDetails.codeVersion) { location.reload(); } }; -// Start initializing the database immediately -void getDatabaseModule(); +// Start initializing the shared worker immediately +void getCommSharedWorker(); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: WebNavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render(): React.Node { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = (): void => electron?.doubleClickTopBar(); stopDoubleClickPropagation: ?(SyntheticEvent) => void = electron ? e => e.stopPropagation() : null; renderLoginPage(): React.Node { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent(): React.Node { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher(): React.Node { const { tab, settingsSection } = this.props.navInfo; let mainContent: React.Node; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'friend-list') { mainContent = null; } else if (settingsSection === 'block-list') { mainContent = null; } else if (settingsSection === 'keyservers') { mainContent = ; } else if (settingsSection === 'build-info') { mainContent = null; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const WEB_TUNNELBROKER_CHANNEL = new BroadcastChannel('shared-tunnelbroker'); const WEB_TUNNELBROKER_MESSAGE_TYPES = Object.freeze({ SEND_MESSAGE: 'send-message', MESSAGE_STATUS: 'message-status', }); function useOtherTabsTunnelbrokerConnection(): SecondaryTunnelbrokerConnection { const onSendMessageCallbacks = React.useRef< Set<(MessageToDeviceRequest) => mixed>, >(new Set()); const onMessageStatusCallbacks = React.useRef< Set<(messageID: string, error: ?string) => mixed>, >(new Set()); React.useEffect(() => { const messageHandler = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { console.log( 'Invalid message received from shared ' + 'tunnelbroker broadcast channel', event.data, ); return; } const data = event.data; if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE) { if (typeof data.message !== 'object' || !data.message) { console.log( 'Invalid tunnelbroker message request received ' + 'from shared tunnelbroker broadcast channel', event.data, ); return; } // We know that the input was already validated const message: MessageToDeviceRequest = (data.message: any); for (const callback of onSendMessageCallbacks.current) { callback(message); } } else if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS) { if (typeof data.messageID !== 'string') { console.log( 'Missing message id in message status message ' + 'from shared tunnelbroker broadcast channel', ); return; } const messageID = data.messageID; if ( typeof data.error !== 'string' && data.error !== null && data.error !== undefined ) { console.log( 'Invalid error in message status message ' + 'from shared tunnelbroker broadcast channel', data.error, ); return; } const error = data.error; for (const callback of onMessageStatusCallbacks.current) { callback(messageID, error); } } else { console.log( 'Invalid message type ' + 'from shared tunnelbroker broadcast channel', data, ); } }; WEB_TUNNELBROKER_CHANNEL.addEventListener('message', messageHandler); return () => WEB_TUNNELBROKER_CHANNEL.removeEventListener('message', messageHandler); }, [onMessageStatusCallbacks, onSendMessageCallbacks]); return React.useMemo( () => ({ sendMessage: message => WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE, message, }), onSendMessage: callback => { onSendMessageCallbacks.current.add(callback); return () => { onSendMessageCallbacks.current.delete(callback); }; }, setMessageStatus: (messageID, error) => { WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS, messageID, error, }); }, onMessageStatus: callback => { onMessageStatusCallbacks.current.add(callback); return () => { onMessageStatusCallbacks.current.delete(callback); }; }, }), [onMessageStatusCallbacks, onSendMessageCallbacks], ); } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); const tunnelbrokerInitMessage = useSelector(createTunnelbrokerInitMessage); const { lockStatus, releaseLockOrAbortRequest } = useWebLock( TUNNELBROKER_LOCK_NAME, ); const secondaryTunnelbrokerConnection: SecondaryTunnelbrokerConnection = useOtherTabsTunnelbrokerConnection(); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/package.json b/web/package.json index 979913c36..4bb1e24a9 100644 --- a/web/package.json +++ b/web/package.json @@ -1,128 +1,128 @@ { "name": "web", "version": "0.0.1", "type": "module", "private": true, "license": "BSD-3-Clause", "scripts": { - "clean": "rm -rf dist/ && rm -rf node_modules/ && rm -rf database/sqlite/ && rm -f backup-client-wasm/wasm/backup-client-wasm_bg.wasm && rm -f backup-client-wasm/wasm/backup-client-wasm.js && rm -rf backup-client-wasm/target", + "clean": "rm -rf dist/ && rm -rf node_modules/ && rm -rf shared-worker/sqlite/ && rm -f backup-client-wasm/wasm/backup-client-wasm_bg.wasm && rm -f backup-client-wasm/wasm/backup-client-wasm.js && rm -rf backup-client-wasm/target", "dev": "yarn workspace keyserver babel-build-comm-config && yarn concurrently --names=\"NODESSR,BROWSER,WORKERS\" -c \"bgBlue.bold,bgMagenta.bold,bgCyan.bold\" \"yarn webpack --config webpack.config.cjs --config-name=server --watch\" \"yarn webpack-dev-server --config webpack.config.cjs --config-name=browser\" \"yarn webpack --config webpack.config.cjs --config-name=webworkers --watch\"", "prod": "yarn workspace keyserver babel-build-comm-config && yarn webpack --config webpack.config.cjs --env prod --progress", "test": "jest", "build-db-wasm": "./scripts/run_emscripten.sh", - "clean-db-wasm": "rm -rf database/_generated/ && rm -rf database/sqlite/", + "clean-db-wasm": "rm -rf shared-worker/_generated/ && rm -rf shared-worker/sqlite/", "build-backup-client-wasm": "./scripts/run-wasmpack.sh", "codegen-identity-grpc": "./scripts/codegen-identity-grpc.sh", "postinstall": "bash ./scripts/postinstall.sh" }, "devDependencies": { "@babel/core": "^7.23.7", "@babel/plugin-transform-class-properties": "^7.23.3", "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", "@babel/plugin-transform-object-rest-spread": "^7.23.4", "@babel/plugin-transform-optional-chaining": "^7.23.4", "@babel/plugin-transform-react-constant-elements": "^7.23.3", "@babel/plugin-transform-runtime": "^7.23.7", "@babel/preset-env": "^7.23.7", "@babel/preset-flow": "^7.23.3", "@babel/preset-react": "^7.23.3", "babel-jest": "^29.7.0", "babel-plugin-transform-remove-console": "^6.9.4", "concurrently": "^5.3.0", "copy-webpack-plugin": "^11.0.0", "flow-bin": "^0.202.1", "flow-typed": "^3.2.1", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "protoc-gen-js": "^3.21.2", "wasm-pack": "^0.12.1", "webpack-cli": "^5.0.1", "webpack-dev-server": "^4.11.1", "webpack-manifest-plugin": "^5.0.0" }, "dependencies": { "@babel/runtime": "^7.23.7", "@commapp/olm": "0.1.1", "@commapp/opaque-ke-wasm": "npm:@commapp/opaque-ke-wasm@^0.0.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@fortawesome/fontawesome-svg-core": "1.2.25", "@fortawesome/free-regular-svg-icons": "5.11.2", "@fortawesome/free-solid-svg-icons": "5.11.2", "@fortawesome/react-fontawesome": "0.1.5", "@matrix-org/olm": "3.2.14", "@rainbow-me/rainbowkit": "^1.1.1", "basscss": "8.0.2", "brotli": "^1.3.3", "classnames": "^2.2.5", "core-js": "^3.6.5", "dateformat": "^3.0.3", "detect-browser": "^4.0.4", "emoji-mart": "^5.5.2", "ethers": "^5.7.2", "exif-js": "^2.3.0", "google-protobuf": "^3.21.2", "grpc-web": "^1.4.2", "history": "^4.6.3", "invariant": "^2.2.4", "is-svg": "^4.3.0", "isomorphic-fetch": "^3.0.0", "lib": "0.0.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "qrcode.react": "^3.1.0", "react": "18.1.0", "react-circular-progressbar": "^2.0.2", "react-color": "^2.13.0", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "18.1.0", "react-feather": "^2.0.3", "react-icomoon": "^2.5.7", "react-icons": "^4.4.0", "react-redux": "^7.1.1", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-switch": "^7.0.0", "react-timeago": "^7.1.0", "react-virtualized-auto-sizer": "^1.0.19", "react-window": "^1.8.9", "redux": "^4.0.4", "redux-devtools-extension": "^2.13.2", "redux-persist": "^6.0.0", "redux-thunk": "^2.2.0", "reselect": "^4.0.0", "simple-markdown": "^0.7.2", "siwe": "^1.1.6", "thumbhash": "^0.1.1", "tinycolor2": "^1.4.1", "uuid": "^3.4.0", "viem": "^1.15.4", "visibilityjs": "^2.0.2", "wagmi": "^1.4.3" }, "jest": { "roots": [ "" ], "transform": { "\\.js$": [ "babel-jest", { "rootMode": "upward" } ] }, "transformIgnorePatterns": [ "/node_modules/(?!(@babel/runtime|thumbhash))" ], "moduleNameMapper": { "\\.(css)$": "identity-obj-proxy" }, "setupFiles": [ "/jest-setup.js" ], "testEnvironment": "jsdom" } } diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index a93b80e1b..f20cf26fd 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,378 +1,378 @@ // @flow import olm from '@commapp/olm'; import localforage from 'localforage'; import { olmEncryptedMessageTypes, type NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, EncryptedWebNotification, } from 'lib/types/notif-types.js'; import { getCookieIDFromCookie } from 'lib/utils/cookie-utils.js'; import { type EncryptedData, decryptData, encryptData, importJWKKey, } from '../crypto/aes-gcm-crypto-utils.js'; +import { initOlm } from '../olm/olm-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, -} from '../database/utils/constants.js'; -import { isDesktopSafari } from '../database/utils/db-utils.js'; -import { initOlm } from '../olm/olm-utils.js'; +} from '../shared-worker/utils/constants.js'; +import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; export type WebNotifDecryptionError = { +id: string, +error: string, +displayErrorMessage?: boolean, }; export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, +decryptedNotification: T, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000; async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { const { id, encryptedPayload } = encryptedNotification; const utilsData = await localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ); if (!utilsData) { return { id, error: 'Necessary data not found in IndexedDB' }; } const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); let olmDBKeys; try { olmDBKeys = await getNotifsOlmSessionDBKeys(); } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } const { olmDataContentKey, encryptionKeyDBKey } = olmDBKeys; const [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataContentKey), retrieveEncryptionKey(encryptionKeyDBKey), ]); if (!encryptionKey || !encryptedOlmData) { return { id, error: 'Received encrypted notification but olm session was not created', displayErrorMessage: staffCanSee, }; } try { await olm.init({ locateFile: () => olmWasmPath }); const decryptedNotification = await commonDecrypt( encryptedOlmData, olmDataContentKey, encryptionKey, encryptedPayload, ); return { id, ...decryptedNotification }; } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } } async function decryptDesktopNotification( encryptedPayload: string, staffCanSee: boolean, ): Promise<{ +[string]: mixed }> { let encryptedOlmData, encryptionKey, olmDataContentKey; try { const { olmDataContentKey: olmDataContentKeyValue, encryptionKeyDBKey } = await getNotifsOlmSessionDBKeys(); olmDataContentKey = olmDataContentKeyValue; [encryptedOlmData, encryptionKey] = await Promise.all([ localforage.getItem(olmDataContentKey), retrieveEncryptionKey(encryptionKeyDBKey), initOlm(), ]); } catch (e) { return { error: e.message, displayErrorMessage: staffCanSee, }; } if (!encryptionKey || !encryptedOlmData) { return { error: 'Received encrypted notification but olm session was not created', displayErrorMessage: staffCanSee, }; } try { return await commonDecrypt( encryptedOlmData, olmDataContentKey, encryptionKey, encryptedPayload, ); } catch (e) { return { error: e.message, staffCanSee, }; } } async function commonDecrypt( encryptedOlmData: EncryptedData, olmDataContentKey: string, encryptionKey: CryptoKey, encryptedPayload: string, ): Promise { const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey); const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, }: NotificationsOlmDataType = JSON.parse( new TextDecoder().decode(serializedOlmData), ); let updatedOlmData: NotificationsOlmDataType; let decryptedNotification: T; const shouldUpdateMainSession = Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; const decryptionWithPendingSessionResult = decryptWithPendingSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); if (decryptionWithPendingSessionResult.decryptedNotification) { const { decryptedNotification: notifDecryptedWithPendingSession, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptionWithPendingSessionResult; decryptedNotification = notifDecryptedWithPendingSession; updatedOlmData = { mainSession: shouldUpdateMainSession ? pendingSessionUpdate : mainSession, pendingSessionUpdate: newPendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } else { const { newUpdateCreationTimestamp, decryptedNotification: notifDecryptedWithMainSession, } = decryptWithSession(mainSession, picklingKey, encryptedPayload); decryptedNotification = notifDecryptedWithMainSession; updatedOlmData = { mainSession: mainSession, pendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } const updatedEncryptedSession = await encryptData( new TextEncoder().encode(JSON.stringify(updatedOlmData)), encryptionKey, ); await localforage.setItem(olmDataContentKey, updatedEncryptedSession); return decryptedNotification; } function decryptWithSession( pickledSession: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); const decryptedNotification: T = JSON.parse( session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), ); const newPendingSessionUpdate = session.pickle(picklingKey); const newUpdateCreationTimestamp = Date.now(); return { decryptedNotification, newUpdateCreationTimestamp, newPendingSessionUpdate, }; } function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptWithSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); return { newPendingSessionUpdate, newUpdateCreationTimestamp, decryptedNotification, }; } catch (e) { return { error: e.message }; } } async function retrieveEncryptionKey( encryptionKeyDBLabel: string, ): Promise { if (!isDesktopSafari) { return await localforage.getItem(encryptionKeyDBLabel); } // Safari doesn't support structured clone algorithm in service // worker context so we have to store CryptoKey as JSON const persistedCryptoKey = await localforage.getItem(encryptionKeyDBLabel); if (!persistedCryptoKey) { return null; } return await importJWKKey(persistedCryptoKey); } async function getNotifsOlmSessionDBKeys(): Promise<{ +olmDataContentKey: string, +encryptionKeyDBKey: string, }> { const dbKeys = await localforage.keys(); const olmDataContentKeys = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(NOTIFICATIONS_OLM_DATA_CONTENT)), ); const encryptionKeyDBLabels = sortOlmDBKeysArray( dbKeys.filter(key => key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)), ); if (olmDataContentKeys.length === 0 || encryptionKeyDBLabels.length === 0) { throw new Error( 'Received encrypted notification but olm session was not created', ); } const latestDataContentKey = olmDataContentKeys[olmDataContentKeys.length - 1]; const latestEncryptionKeyDBKey = encryptionKeyDBLabels[encryptionKeyDBLabels.length - 1]; const latestDataContentCookieID = getCookieIDFromOlmDBKey(latestDataContentKey); const latestEncryptionKeyCookieID = getCookieIDFromOlmDBKey( latestEncryptionKeyDBKey, ); if (latestDataContentCookieID !== latestEncryptionKeyCookieID) { throw new Error( 'Olm sessions and their encryption keys out of sync. Latest cookie ' + `id for olm sessions ${latestDataContentCookieID}. Latest cookie ` + `id for olm session encryption keys ${latestEncryptionKeyCookieID}`, ); } const olmDBKeys = { olmDataContentKey: latestDataContentKey, encryptionKeyDBKey: latestEncryptionKeyDBKey, }; const keysToDelete: $ReadOnlyArray = [ ...olmDataContentKeys.slice(0, olmDataContentKeys.length - 1), ...encryptionKeyDBLabels.slice(0, encryptionKeyDBLabels.length - 1), ]; await Promise.all(keysToDelete.map(key => localforage.removeItem(key))); return olmDBKeys; } function getOlmDataContentKeyForCookie( cookie: ?string, // eslint-disable-next-line no-unused-vars keyserverID: string, ): string { if (!cookie) { return NOTIFICATIONS_OLM_DATA_CONTENT; } const cookieID = getCookieIDFromCookie(cookie); return `${NOTIFICATIONS_OLM_DATA_CONTENT}:${cookieID}`; } function getOlmEncryptionKeyDBLabelForCookie(cookie: ?string): string { if (!cookie) { return NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY; } const cookieID = getCookieIDFromCookie(cookie); return `${NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY}:${cookieID}`; } function getCookieIDFromOlmDBKey(olmDBKey: string): string | '0' { const cookieID = olmDBKey.split(':')[1]; return cookieID ?? '0'; } function sortOlmDBKeysArray( olmDBKeysArray: $ReadOnlyArray, ): $ReadOnlyArray { return olmDBKeysArray .map(key => ({ cookieID: Number(getCookieIDFromOlmDBKey(key)), key, })) .sort( ({ cookieID: cookieID1 }, { cookieID: cookieID2 }) => cookieID1 - cookieID2, ) .map(({ key }) => key); } export { decryptWebNotification, decryptDesktopNotification, getOlmDataContentKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, }; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index 0f9cc68c3..fc3cd4319 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,231 +1,231 @@ // @flow import * as React from 'react'; import { useSetDeviceTokenFanout, setDeviceTokenActionTypes, } from 'lib/actions/device-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert, recordNotifPermissionAlertActionType, } from 'lib/utils/push-alerts.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { decryptDesktopNotification } from './notif-crypto-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; -import { - WORKERS_MODULES_DIR_PATH, - DEFAULT_OLM_FILENAME, -} from '../database/utils/constants.js'; import electron from '../electron.js'; import PushNotifModal from '../modals/push-notif-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; +import { + WORKERS_MODULES_DIR_PATH, + DEFAULT_OLM_FILENAME, +} from '../shared-worker/utils/constants.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; declare var baseURL: string; declare var olmFilename: string; function useCreateDesktopPushSubscription() { const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); React.useEffect( () => electron?.onDeviceTokenRegistered?.((token: ?string) => { void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), ); }), [callSetDeviceToken, dispatchActionPromise], ); React.useEffect(() => { electron?.fetchDeviceToken?.(); }, []); React.useEffect( () => electron?.onEncryptedNotification?.( async ({ encryptedPayload }: { encryptedPayload: string }) => { const decryptedPayload = await decryptDesktopNotification( encryptedPayload, staffCanSee, ); electron?.showDecryptedNotification(decryptedPayload); }, ), [staffCanSee], ); const dispatch = useDispatch(); React.useEffect( () => electron?.onNotificationClicked?.( ({ threadID }: { +threadID: string }) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, authoritativeKeyserverID, ); const payload = { chatMode: 'view', activeChatThreadID: convertedThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); }, ), [dispatch], ); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; if (!workerRegistration || !workerRegistration.pushManager) { return; } const origin = window.location.origin; const olmWasmDirPath = `${origin}${baseURL}${WORKERS_MODULES_DIR_PATH}`; const olmWasmFilename = olmFilename ? olmFilename : DEFAULT_OLM_FILENAME; const olmWasmPath = `${olmWasmDirPath}/${olmWasmFilename}`; workerRegistration.active?.postMessage({ olmWasmPath, staffCanSee }); const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(JSON.stringify(subscription)), ); }, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const modalContext = useModalContext(); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const supported = 'Notification' in window && !electron; React.useEffect(() => { void (async () => { if (!navigator.serviceWorker || !supported) { return; } await navigator.serviceWorker.register('worker/notif', { scope: '/' }); if (Notification.permission === 'granted') { // Make sure the subscription is current if we have the permissions await createPushSubscription(); } else if ( Notification.permission === 'default' && loggedIn && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { // Ask existing users that are already logged in for permission modalContext.pushModal(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Ask for permission on login const prevLoggedIn = React.useRef(loggedIn); React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return; } if (!prevLoggedIn.current && loggedIn) { if (Notification.permission === 'granted') { void createPushSubscription(); } else if ( Notification.permission === 'default' && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { modalContext.pushModal(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } } prevLoggedIn.current = loggedIn; }, [ createPushSubscription, dispatch, loggedIn, modalContext, notifPermissionAlertInfo, prevLoggedIn, supported, ]); // Redirect to thread on notification click React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return undefined; } const callback = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { return; } if (event.data.targetThreadID) { const payload = { chatMode: 'view', activeChatThreadID: event.data.targetThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); } }; navigator.serviceWorker.addEventListener('message', callback); return () => navigator.serviceWorker?.removeEventListener('message', callback); }, [dispatch, supported]); return null; } export { PushNotificationsHandler, useCreatePushSubscription }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 8f596ad2f..69597eaaa 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,176 +1,176 @@ // @flow import localforage from 'localforage'; import type { PlainTextWebNotification, WebNotification, } from 'lib/types/notif-types.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { decryptWebNotification, WEB_NOTIFS_SERVICE_UTILS_KEY, type WebNotifsServiceUtilsData, type WebNotifDecryptionError, } from './notif-crypto-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; -import { localforageConfig } from '../database/utils/constants.js'; +import { localforageConfig } from '../shared-worker/utils/constants.js'; declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } declare class CommAppMessage extends ExtendableEvent { +data: { +olmWasmPath?: string, +staffCanSee?: boolean }; } declare var clients: Clients; declare function skipWaiting(): Promise; const commIconUrl = 'https://web.comm.app/favicon.ico'; function buildDecryptionErrorNotification( decryptionError: WebNotifDecryptionError, ) { const baseErrorPayload = { badge: commIconUrl, icon: commIconUrl, tag: decryptionError.id, data: { isError: true, }, }; if (decryptionError.displayErrorMessage && decryptionError.error) { return { body: decryptionError.error, ...baseErrorPayload, }; } return baseErrorPayload; } self.addEventListener('install', skipWaiting); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); self.addEventListener('message', (event: CommAppMessage) => { localforage.config(localforageConfig); event.waitUntil( (async () => { if (!event.data.olmWasmPath || event.data.staffCanSee === undefined) { return; } const webNotifsServiceUtils: WebNotifsServiceUtilsData = { olmWasmPath: event.data.olmWasmPath, staffCanSee: event.data.staffCanSee, }; await localforage.setItem( WEB_NOTIFS_SERVICE_UTILS_KEY, webNotifsServiceUtils, ); })(), ); }); self.addEventListener('push', (event: PushEvent) => { localforage.config(localforageConfig); const data: WebNotification = event.data.json(); event.waitUntil( (async () => { let plainTextData: PlainTextWebNotification; let decryptionResult: PlainTextWebNotification | WebNotifDecryptionError; if (data.encryptedPayload) { decryptionResult = await decryptWebNotification(data); } if (decryptionResult && decryptionResult.error) { const decryptionErrorNotification = buildDecryptionErrorNotification(decryptionResult); await self.registration.showNotification( 'Comm notification', decryptionErrorNotification, ); return; } else if (decryptionResult && decryptionResult.body) { plainTextData = decryptionResult; } else if (data.body) { plainTextData = data; } else { // We will never enter ths branch. It is // necessary since flow doesn't differentiate // between union types out-of-the-box. return; } let body = plainTextData.body; if (data.prefix) { body = `${data.prefix} ${body}`; } await self.registration.showNotification(plainTextData.title, { body, badge: commIconUrl, icon: commIconUrl, tag: plainTextData.id, data: { unreadCount: plainTextData.unreadCount, threadID: plainTextData.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; // Decryption error notifications don't contain threadID // but we still want them to be interactive in terms of basic // navigation. let threadID; if (!event.notification.data.isError) { threadID = convertNonPendingIDToNewSchema( event.notification.data.threadID, authoritativeKeyserverID, ); } if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } if (threadID) { selectedClient.postMessage({ targetThreadID: threadID, }); } } else { const baseURL = process.env.NODE_ENV === 'production' ? 'https://web.comm.app' : 'http://localhost:3000/webapp'; const url = threadID ? baseURL + `/chat/thread/${threadID}/` : baseURL; await clients.openWindow(url); } })(), ); }); diff --git a/web/redux/comm-redux-storage-engine.js b/web/redux/comm-redux-storage-engine.js index bfbba011c..899aae69b 100644 --- a/web/redux/comm-redux-storage-engine.js +++ b/web/redux/comm-redux-storage-engine.js @@ -1,54 +1,54 @@ // @flow import storage from 'redux-persist/es/storage/index.js'; -import { getDatabaseModule } from '../database/database-module-provider.js'; +import { getCommSharedWorker } from '../shared-worker/shared-worker-provider.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; const commReduxStorageEngine = { getItem: async (key: string): Promise => { - const databaseModule = await getDatabaseModule(); - const isSupported = await databaseModule.isDatabaseSupported(); + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); if (!isSupported) { return await storage.getItem(key); } - const result = await databaseModule.schedule({ + const result = await sharedWorker.schedule({ type: workerRequestMessageTypes.GET_PERSIST_STORAGE_ITEM, key, }); if (!result || typeof result.item !== 'string') { throw new Error('Wrong type returned for storage item'); } return result.item; }, setItem: async (key: string, item: string): Promise => { - const databaseModule = await getDatabaseModule(); - const isSupported = await databaseModule.isDatabaseSupported(); + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); if (!isSupported) { await storage.setItem(key, item); return; } - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.SET_PERSIST_STORAGE_ITEM, key, item, }); }, removeItem: async (key: string): Promise => { - const databaseModule = await getDatabaseModule(); - const isSupported = await databaseModule.isDatabaseSupported(); + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); if (!isSupported) { await storage.removeItem(key); return; } - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.REMOVE_PERSIST_STORAGE_ITEM, key, }); }, }; export default commReduxStorageEngine; diff --git a/web/redux/initial-state-gate.js b/web/redux/initial-state-gate.js index e0ae7e563..34773c8f0 100644 --- a/web/redux/initial-state-gate.js +++ b/web/redux/initial-state-gate.js @@ -1,157 +1,157 @@ // @flow import * as React from 'react'; import { PersistGate } from 'redux-persist/es/integration/react.js'; import type { Persistor } from 'redux-persist/es/types'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { allUpdatesCurrentAsOfSelector } from 'lib/selectors/keyserver-selectors.js'; import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import type { RawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { LegacyRawThreadInfo } from 'lib/types/thread-types.js'; import { convertIDToNewSchema } from 'lib/utils/migration-utils.js'; import { entries } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { setInitialReduxState, useGetInitialReduxState, } from './action-types.js'; import { useSelector } from './redux-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; +import Loading from '../loading.react.js'; import { getClientDBStore, processDBStoreOperations, -} from '../database/utils/store.js'; -import Loading from '../loading.react.js'; +} from '../shared-worker/utils/store.js'; type Props = { +persistor: Persistor, +children: React.Node, }; function InitialReduxStateGate(props: Props): React.Node { const { children, persistor } = props; const callGetInitialReduxState = useGetInitialReduxState(); const dispatch = useDispatch(); const [initError, setInitError] = React.useState(null); React.useEffect(() => { if (initError) { throw initError; } }, [initError]); const isRehydrated = useSelector(state => !!state._persist?.rehydrated); const allUpdatesCurrentAsOf = useSelector(allUpdatesCurrentAsOfSelector); const prevIsRehydrated = React.useRef(false); React.useEffect(() => { if (prevIsRehydrated.current || !isRehydrated) { return; } prevIsRehydrated.current = isRehydrated; void (async () => { try { let urlInfo = infoFromURL(decodeURI(window.location.href)); // Handle older links if (urlInfo.thread) { urlInfo = { ...urlInfo, thread: convertIDToNewSchema( urlInfo.thread, authoritativeKeyserverID, ), }; } const clientDBStore = await getClientDBStore(); dispatch({ type: setClientDBStoreActionType, payload: clientDBStore, }); const payload = await callGetInitialReduxState({ urlInfo, excludedData: { threadStore: !!clientDBStore.threadStore, }, allUpdatesCurrentAsOf, }); const currentLoggedInUserID = payload.currentUserInfo?.anonymous ? null : payload.currentUserInfo?.id; const useDatabase = canUseDatabaseOnWeb(currentLoggedInUserID); if (!currentLoggedInUserID || !useDatabase) { dispatch({ type: setInitialReduxState, payload }); return; } if (clientDBStore.threadStore) { const { threadStore, ...rest } = payload; dispatch({ type: setInitialReduxState, payload: rest }); return; } // When there is no data in the DB, it's necessary to migrate data // from the keyserver payload to the DB const { threadStore: { threadInfos }, } = payload; const threadStoreOperations: ThreadStoreOperation[] = entries( threadInfos, ).map( ([id, threadInfo]: [ string, LegacyRawThreadInfo | RawThreadInfo, ]) => ({ type: 'replace', payload: { id, threadInfo, }, }), ); await processDBStoreOperations( { threadStoreOperations, draftStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], keyserverStoreOperations: [], communityStoreOperations: [], }, currentLoggedInUserID, ); dispatch({ type: setInitialReduxState, payload }); } catch (err) { setInitError(err); } })(); }, [callGetInitialReduxState, dispatch, isRehydrated, allUpdatesCurrentAsOf]); const initialStateLoaded = useSelector(state => state.initialStateLoaded); const childFunction = React.useCallback( // This argument is passed from `PersistGate`. It means that the state is // rehydrated and we can start fetching the initial info. (bootstrapped: boolean) => { if (bootstrapped && initialStateLoaded) { return children; } else { return ; } }, [children, initialStateLoaded], ); return {childFunction}; } export default InitialReduxStateGate; diff --git a/web/redux/persist.js b/web/redux/persist.js index 3a1293f93..0c2165a94 100644 --- a/web/redux/persist.js +++ b/web/redux/persist.js @@ -1,314 +1,314 @@ // @flow import invariant from 'invariant'; import { getStoredState, purgeStoredState } from 'redux-persist'; import storage from 'redux-persist/es/storage/index.js'; import type { PersistConfig } from 'redux-persist/src/types.js'; import { type ClientDBKeyserverStoreOperation, keyserverStoreOpsHandlers, type ReplaceKeyserverOperation, } from 'lib/ops/keyserver-store-ops.js'; import { createAsyncMigrate, type StorageMigrationFunction, } from 'lib/shared/create-async-migrate.js'; import { keyserverStoreTransform } from 'lib/shared/transforms/keyserver-store-transform.js'; import type { KeyserverInfo } from 'lib/types/keyserver-types.js'; import { cookieTypes } from 'lib/types/session-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import { parseCookies } from 'lib/utils/cookie-utils.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { wipeKeyserverStore } from 'lib/utils/keyserver-store-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertDraftStoreToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { entries } from 'lib/utils/objects.js'; import { resetUserSpecificState } from 'lib/utils/reducers-utils.js'; import commReduxStorageEngine from './comm-redux-storage-engine.js'; import { defaultWebState } from './default-state.js'; import { rootKey, rootKeyPrefix } from './persist-constants.js'; import type { AppState } from './redux-setup.js'; import { nonUserSpecificFieldsWeb } from './redux-setup.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; -import { getDatabaseModule } from '../database/database-module-provider.js'; -import { isSQLiteSupported } from '../database/utils/db-utils.js'; +import { getCommSharedWorker } from '../shared-worker/shared-worker-provider.js'; +import { isSQLiteSupported } from '../shared-worker/utils/db-utils.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; declare var keyserverURL: string; const persistWhitelist = [ 'enabledApps', 'cryptoStore', 'notifPermissionAlertInfo', 'commServicesAccessToken', 'keyserverStore', 'globalThemeInfo', 'customServer', ]; function handleReduxMigrationFailure(oldState: AppState): AppState { const persistedNonUserSpecificFields = nonUserSpecificFieldsWeb.filter( field => persistWhitelist.includes(field) || field === '_persist', ); const stateAfterReset = resetUserSpecificState( oldState, defaultWebState, persistedNonUserSpecificFields, ); return { ...stateAfterReset, keyserverStore: wipeKeyserverStore(stateAfterReset.keyserverStore), }; } const migrations = { [1]: async (state: any) => { const { primaryIdentityPublicKey, ...stateWithoutPrimaryIdentityPublicKey } = state; return { ...stateWithoutPrimaryIdentityPublicKey, cryptoStore: { primaryAccount: null, primaryIdentityKeys: null, notificationAccount: null, notificationIdentityKeys: null, }, }; }, [2]: async (state: AppState) => { return state; }, [3]: async (state: AppState) => { let newState = state; if (state.draftStore) { newState = { ...newState, draftStore: convertDraftStoreToNewIDSchema(state.draftStore), }; } - const databaseModule = await getDatabaseModule(); - const isDatabaseSupported = await databaseModule.isDatabaseSupported(); + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); - if (!isDatabaseSupported) { + if (!isSupported) { return newState; } - const stores = await databaseModule.schedule({ + const stores = await sharedWorker.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); invariant(stores?.store, 'Stores should exist'); - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations: generateIDSchemaMigrationOpsForDrafts( stores.store.drafts, ), }, }); return newState; }, [4]: async (state: any) => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverStore.keyserverInfos[authoritativeKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [5]: async (state: any) => { - const databaseModule = await getDatabaseModule(); - const isDatabaseSupported = await databaseModule.isDatabaseSupported(); - if (!isDatabaseSupported) { + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); + if (!isSupported) { return state; } if (!state.draftStore) { return state; } const { drafts } = state.draftStore; const draftStoreOperations = []; for (const key in drafts) { const text = drafts[key]; draftStoreOperations.push({ type: 'update', payload: { key, text }, }); } - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations }, }); return state; }, [6]: async (state: AppState) => ({ ...state, integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, }), [7]: async (state: AppState): Promise => { if (!document.cookie) { return state; } const params = parseCookies(document.cookie); let cookie = null; if (params[cookieTypes.USER]) { cookie = `${cookieTypes.USER}=${params[cookieTypes.USER]}`; } else if (params[cookieTypes.ANONYMOUS]) { cookie = `${cookieTypes.ANONYMOUS}=${params[cookieTypes.ANONYMOUS]}`; } return { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...state.keyserverStore.keyserverInfos[authoritativeKeyserverID], cookie, }, }, }, }; }, [8]: async (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [9]: async (state: AppState) => ({ ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...state.keyserverStore.keyserverInfos[authoritativeKeyserverID], urlPrefix: keyserverURL, }, }, }, }), [10]: async (state: AppState) => { const { keyserverInfos } = state.keyserverStore; const newKeyserverInfos: { [string]: KeyserverInfo } = {}; for (const key in keyserverInfos) { newKeyserverInfos[key] = { ...keyserverInfos[key], connection: { ...defaultConnectionInfo }, updatesCurrentAsOf: 0, sessionID: null, }; } return { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, }; }, [11]: async (state: AppState) => { - const databaseModule = await getDatabaseModule(); - const isDatabaseSupported = await databaseModule.isDatabaseSupported(); - if (!isDatabaseSupported) { + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); + if (!isSupported) { return state; } const replaceOps: $ReadOnlyArray = entries( state.keyserverStore.keyserverInfos, ).map(([id, keyserverInfo]) => ({ type: 'replace_keyserver', payload: { id, keyserverInfo, }, })); const keyserverStoreOperations: $ReadOnlyArray = keyserverStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_keyservers' }, ...replaceOps, ]); try { - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { keyserverStoreOperations }, }); return state; } catch (e) { console.log(e); return handleReduxMigrationFailure(state); } }, }; const migrateStorageToSQLite: StorageMigrationFunction = async debug => { - const databaseModule = await getDatabaseModule(); - const isSupported = await databaseModule.isDatabaseSupported(); + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); if (!isSupported) { return undefined; } const oldStorage = await getStoredState({ storage, key: rootKey }); if (!oldStorage) { return undefined; } purgeStoredState({ storage, key: rootKey }); if (debug) { console.log('redux-persist: migrating state to SQLite storage'); } const allKeys = Object.keys(oldStorage); const transforms = persistConfig.transforms ?? []; const newStorage = { ...oldStorage }; for (const transform of transforms) { for (const key of allKeys) { const transformedStore = transform.out(newStorage[key], key, newStorage); newStorage[key] = transformedStore; } } return newStorage; }; const persistConfig: PersistConfig = { keyPrefix: rootKeyPrefix, key: rootKey, storage: commReduxStorageEngine, whitelist: isSQLiteSupported() ? persistWhitelist : [...persistWhitelist, 'draftStore'], migrate: (createAsyncMigrate( migrations, { debug: isDev }, migrateStorageToSQLite, ): any), version: 11, transforms: [keyserverStoreTransform], }; export { persistConfig }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index d7f9d5e70..85883f969 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,492 +1,492 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/es/types.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, deleteAccountActionTypes, identityRegisterActionTypes, } from 'lib/actions/user-actions.js'; import { setNewSessionActionType } from 'lib/keyserver-conn/keyserver-conn-types.js'; import { type ReplaceKeyserverOperation, keyserverStoreOpsHandlers, } from 'lib/ops/keyserver-store-ops.js'; import { type ThreadStoreOperation, threadStoreOpsHandlers, } from 'lib/ops/thread-store-ops.js'; import { reduceLoadingStatuses } from 'lib/reducers/loading-reducer.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { invalidSessionDowngrade, identityInvalidSessionDowngrade, } from 'lib/shared/session-utils.js'; import type { CommunityStore } from 'lib/types/community-types.js'; import type { CryptoStore } from 'lib/types/crypto-types.js'; import type { DraftStore } from 'lib/types/draft-types.js'; import type { EnabledApps } from 'lib/types/enabled-apps.js'; import type { EntryStore } from 'lib/types/entry-types.js'; import { type CalendarFilter } from 'lib/types/filter-types.js'; import type { IntegrityStore } from 'lib/types/integrity-types.js'; import type { KeyserverStore } from 'lib/types/keyserver-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MessageStore } from 'lib/types/message-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { UserPolicies } from 'lib/types/policy-types.js'; import type { BaseAction } from 'lib/types/redux-types.js'; import type { ReportStore } from 'lib/types/report-types.js'; import type { StoreOperations } from 'lib/types/store-ops-types.js'; import type { GlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ThreadActivityStore } from 'lib/types/thread-activity-types'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types.js'; import type { NotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { resetUserSpecificState } from 'lib/utils/reducers-utils.js'; import { updateWindowActiveActionType, updateNavInfoActionType, updateWindowDimensionsActionType, setInitialReduxState, } from './action-types.js'; import { reduceCommunityPickerStore } from './community-picker-reducer.js'; import { reduceCryptoStore, setCryptoStore } from './crypto-store-reducer.js'; import { defaultWebState } from './default-state.js'; import reduceNavInfo from './nav-reducer.js'; import { onStateDifference } from './redux-debug-utils.js'; import { reduceServicesAccessToken } from './services-access-token-reducer.js'; import { getVisibility } from './visibility.js'; -import { processDBStoreOperations } from '../database/utils/store.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; +import { processDBStoreOperations } from '../shared-worker/utils/store.js'; import type { InitialReduxState } from '../types/redux-types.js'; export type WindowDimensions = { width: number, height: number }; export type CommunityPickerStore = { +chat: ?string, +calendar: ?string, }; const nonUserSpecificFieldsWeb = [ 'loadingStatuses', 'windowDimensions', 'lifecycleState', 'nextLocalID', 'windowActive', 'pushApiPublicKey', 'keyserverStore', 'initialStateLoaded', '_persist', 'customServer', ]; export type AppState = { +navInfo: WebNavInfo, +currentUserInfo: ?CurrentUserInfo, +draftStore: DraftStore, +entryStore: EntryStore, +threadStore: ThreadStore, +userStore: UserStore, +messageStore: MessageStore, +loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, +calendarFilters: $ReadOnlyArray, +communityPickerStore: CommunityPickerStore, +windowDimensions: WindowDimensions, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +watchedThreadIDs: $ReadOnlyArray, +lifecycleState: LifecycleState, +enabledApps: EnabledApps, +reportStore: ReportStore, +nextLocalID: number, +dataLoaded: boolean, +windowActive: boolean, +userPolicies: UserPolicies, +cryptoStore: ?CryptoStore, +pushApiPublicKey: ?string, +_persist: ?PersistState, +commServicesAccessToken: ?string, +inviteLinksStore: InviteLinksStore, +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +initialStateLoaded: boolean, +integrityStore: IntegrityStore, +globalThemeInfo: GlobalThemeInfo, +customServer: ?string, +communityStore: CommunityStore, }; export type Action = | BaseAction | { +type: 'UPDATE_NAV_INFO', +payload: Partial } | { +type: 'UPDATE_WINDOW_DIMENSIONS', +payload: WindowDimensions, } | { +type: 'UPDATE_WINDOW_ACTIVE', +payload: boolean, } | { +type: 'SET_CRYPTO_STORE', +payload: CryptoStore } | { +type: 'SET_INITIAL_REDUX_STATE', +payload: InitialReduxState }; function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; let storeOperations: StoreOperations = { draftStoreOperations: [], threadStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], keyserverStoreOperations: [], communityStoreOperations: [], }; if (action.type === setInitialReduxState) { const { userInfos, keyserverInfos, actualizedCalendarQuery, ...rest } = action.payload; const replaceOperations: ReplaceKeyserverOperation[] = []; for (const keyserverID in keyserverInfos) { replaceOperations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverStore.keyserverInfos[keyserverID], ...keyserverInfos[keyserverID], actualizedCalendarQuery, }, }, }); } return validateStateAndProcessDBOperations( action, oldState, { ...state, ...rest, userStore: { userInfos }, keyserverStore: keyserverStoreOpsHandlers.processStoreOperations( state.keyserverStore, replaceOperations, ), initialStateLoaded: true, }, { ...storeOperations, keyserverStoreOperations: [ ...storeOperations.keyserverStoreOperations, ...replaceOperations, ], }, ); } else if (action.type === updateWindowDimensionsActionType) { return validateStateAndProcessDBOperations( action, oldState, { ...state, windowDimensions: action.payload, }, storeOperations, ); } else if (action.type === updateWindowActiveActionType) { return validateStateAndProcessDBOperations( action, oldState, { ...state, windowActive: action.payload, }, storeOperations, ); } else if (action.type === setNewSessionActionType) { const { keyserverID, sessionChange } = action.payload; if (!state.keyserverStore.keyserverInfos[keyserverID]) { if (sessionChange.cookie?.startsWith('user=')) { console.log( 'received sessionChange with user cookie, ' + `but keyserver ${keyserverID} is not in KeyserverStore!`, ); } return state; } if ( invalidSessionDowngrade( oldState, sessionChange.currentUserInfo, action.payload.preRequestUserState, keyserverID, ) ) { return { ...oldState, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } const replaceOperation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverStore.keyserverInfos[keyserverID], sessionID: sessionChange.sessionID, }, }, }; state = { ...state, keyserverStore: keyserverStoreOpsHandlers.processStoreOperations( state.keyserverStore, [replaceOperation], ), }; storeOperations = { ...storeOperations, keyserverStoreOperations: [ ...storeOperations.keyserverStoreOperations, replaceOperation, ], }; } else if (action.type === deleteKeyserverAccountActionTypes.success) { const { currentUserInfo, preRequestUserState } = action.payload; const newKeyserverIDs = []; for (const keyserverID of action.payload.keyserverIDs) { if ( invalidSessionDowngrade( state, currentUserInfo, preRequestUserState, keyserverID, ) ) { continue; } newKeyserverIDs.push(keyserverID); } if (newKeyserverIDs.length === 0) { return { ...state, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } action = { ...action, payload: { ...action.payload, keyserverIDs: newKeyserverIDs, }, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { const { currentUserInfo, preRequestUserState } = action.payload; if ( identityInvalidSessionDowngrade( oldState, currentUserInfo, preRequestUserState, ) ) { return { ...oldState, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } state = resetUserSpecificState( state, defaultWebState, nonUserSpecificFieldsWeb, ); } else if (action.type === identityRegisterActionTypes.success) { state = resetUserSpecificState( state, defaultWebState, nonUserSpecificFieldsWeb, ); } if ( action.type !== updateNavInfoActionType && action.type !== setCryptoStore ) { const baseReducerResult = baseReducer(state, action, onStateDifference); state = baseReducerResult.state; storeOperations = { ...baseReducerResult.storeOperations, keyserverStoreOperations: [ ...storeOperations.keyserverStoreOperations, ...baseReducerResult.storeOperations.keyserverStoreOperations, ], }; } const communityPickerStore = reduceCommunityPickerStore( state.communityPickerStore, action, ); state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), cryptoStore: reduceCryptoStore(state.cryptoStore, action), communityPickerStore, commServicesAccessToken: reduceServicesAccessToken( state.commServicesAccessToken, action, ), }; return validateStateAndProcessDBOperations( action, oldState, state, storeOperations, ); } function validateStateAndProcessDBOperations( action: Action, oldState: AppState, state: AppState, storeOperations: StoreOperations, ): AppState { const updateActiveThreadOps: ThreadStoreOperation[] = []; if ( (state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID]) || (!state.navInfo.activeChatThreadID && isLoggedIn(state)) ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentlyReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && getVisibility().hidden() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but visibilityjs reports the window is not visible', ); } if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && typeof document !== 'undefined' && document && 'hasFocus' in document && !document.hasFocus() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but document.hasFocus() is false', ); } if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && 'hasFocus' in document && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread const activeThreadInfo = state.threadStore.threadInfos[activeThread]; updateActiveThreadOps.push({ type: 'replace', payload: { id: activeThread, threadInfo: { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }, }, }); } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { const now = Date.now(); state = { ...state, threadActivityStore: { ...state.threadActivityStore, [(activeThread: string)]: { ...state.threadActivityStore[activeThread], lastNavigatedTo: now, }, }, }; } if (updateActiveThreadOps.length > 0) { state = { ...state, threadStore: threadStoreOpsHandlers.processStoreOperations( state.threadStore, updateActiveThreadOps, ), }; storeOperations = { ...storeOperations, threadStoreOperations: [ ...storeOperations.threadStoreOperations, ...updateActiveThreadOps, ], }; } // The operations were already dispatched from the main tab // For now the `dispatchSource` field is not included in any of the // redux actions and this causes flow to throw an error. // As soon as one of the actions is updated, this fix (and the corresponding // one in tab-synchronization.js) can be removed. // $FlowFixMe if (action.dispatchSource !== 'tab-sync') { void processDBStoreOperations( storeOperations, state.currentUserInfo?.id ?? null, ); } return state; } export { nonUserSpecificFieldsWeb, reducer }; diff --git a/web/root.js b/web/root.js index 2c4d0ba75..edc060a3f 100644 --- a/web/root.js +++ b/web/root.js @@ -1,77 +1,77 @@ // @flow import localforage from 'localforage'; import * as React from 'react'; import { Provider } from 'react-redux'; import { Router, Route } from 'react-router'; import { createStore, applyMiddleware, type Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction.js'; import { persistReducer, persistStore } from 'redux-persist'; import thunk from 'redux-thunk'; import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import KeyserverConnectionsHandler from 'lib/components/keyserver-connections-handler.js'; import PrekeysHandler from 'lib/components/prekeys-handler.react.js'; import ReportHandler from 'lib/components/report-handler.react.js'; import { CallKeyserverEndpointProvider } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { GetOrCreateCryptoStoreProvider, OlmSessionCreatorProvider, } from './account/account-hooks.js'; import App from './app.react.js'; -import { SQLiteDataHandler } from './database/sqlite-data-handler.js'; -import { localforageConfig } from './database/utils/constants.js'; import ErrorBoundary from './error-boundary.react.js'; import IdentityServiceContextProvider from './grpc/identity-service-context-provider.react.js'; import { defaultWebState } from './redux/default-state.js'; import InitialReduxStateGate from './redux/initial-state-gate.js'; import { persistConfig } from './redux/persist.js'; import { type AppState, type Action, reducer } from './redux/redux-setup.js'; import { synchronizeStoreWithOtherTabs, tabSynchronizationMiddleware, } from './redux/tab-synchronization.js'; import history from './router-history.js'; +import { SQLiteDataHandler } from './shared-worker/sqlite-data-handler.js'; +import { localforageConfig } from './shared-worker/utils/constants.js'; import Socket from './socket.react.js'; localforage.config(localforageConfig); const persistedReducer = persistReducer(persistConfig, reducer); const store: Store = createStore( persistedReducer, defaultWebState, composeWithDevTools({})( applyMiddleware(thunk, reduxLoggerMiddleware, tabSynchronizationMiddleware), ), ); synchronizeStoreWithOtherTabs(store); const persistor = persistStore(store); const RootProvider = (): React.Node => ( ); export default RootProvider; diff --git a/web/scripts/run_emscripten.sh b/web/scripts/run_emscripten.sh index f6feefb5a..94bc86146 100755 --- a/web/scripts/run_emscripten.sh +++ b/web/scripts/run_emscripten.sh @@ -1,171 +1,171 @@ #!/usr/bin/env bash set -Eeuo pipefail if ! command -v emcc > /dev/null; then echo "Please install emscripten or run 'nix develop'" >&2 exit 1 fi # directories SCRIPT_DIR=$(cd "$(dirname "$0")"; pwd -P) NATIVE_CPP_DIR="${SCRIPT_DIR}/../../native/cpp/" INPUT_DIR="${NATIVE_CPP_DIR}CommonCpp/DatabaseManagers/" ENTITIES_DIR="${NATIVE_CPP_DIR}CommonCpp/DatabaseManagers/entities/" -SQLITE_DIR="${SCRIPT_DIR}/../database/sqlite/" +SQLITE_DIR="${SCRIPT_DIR}/../shared-worker/sqlite/" WEB_CPP_DIR="${SCRIPT_DIR}/../cpp/" -OUTPUT_DIR="${SCRIPT_DIR}/../database/_generated/" +OUTPUT_DIR="${SCRIPT_DIR}/../shared-worker/_generated/" # files SQLITE_SOURCE="${SQLITE_DIR}sqlite3.c" SQLITE_BITCODE_FILE="${SQLITE_DIR}sqlite3.bc" OUTPUT_FILE_NAME="comm-query-executor" OUTPUT_FILE="${OUTPUT_DIR}${OUTPUT_FILE_NAME}.js" # OpenSSL resources OPENSSL_VERSION="3.2.0" OPENSSL_URL="https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz" OPENSSL_FILE="${SQLITE_DIR}openssl-file" OPENSSL_DIR="${SQLITE_DIR}openssl-${OPENSSL_VERSION}" OPENSSL_HEADERS="${OPENSSL_DIR}/include" OPENSSL_LIBCRYPTO="${OPENSSL_DIR}/libcrypto.a" # SQLCipher resources SQLCIPHER_AMALGAMATION_VERSION="4.5.5-d" SQLCIPHER_AMALGAMATION="sqlcipher-amalgamation-${SQLCIPHER_AMALGAMATION_VERSION}" SQLCIPHER_AMALGAMATION_URL="https://codeload.github.com/CommE2E/sqlcipher-amalgamation/zip/refs/tags/${SQLCIPHER_AMALGAMATION_VERSION}" SQLCIPHER_AMALGAMATION_FILE="${SQLITE_DIR}${SQLCIPHER_AMALGAMATION}" SQLITE_COMPILATION_FLAGS=( -Oz -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_DISABLE_LFS -DSQLITE_ENABLE_FTS3 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_THREADSAFE=0 -DSQLITE_ENABLE_NORMALIZE -DSQLITE_HAS_CODEC -DSQLITE_TEMP_STORE=2 -DSQLCIPHER_CRYPTO_OPENSSL -DSQLITE_ENABLE_SESSION -DSQLITE_ENABLE_PREUPDATE_HOOK ) download_openssl() { mkdir -p "$SQLITE_DIR" curl "${OPENSSL_URL}" --output "${OPENSSL_FILE}" tar -xf "${OPENSSL_FILE}" -C "${SQLITE_DIR}" rm -f "${OPENSSL_FILE}" } build_openssl() { pushd "${OPENSSL_DIR}" ./Configure \ no-asm \ no-async \ no-egd \ no-ktls \ no-module \ no-posix-io \ no-secure-memory \ no-dso \ no-shared \ no-sock \ no-stdio \ no-ui-console \ no-weak-ssl-ciphers \ no-engine \ linux-generic32 make CC="emcc" AR="emar" RANLIB="emranlib" popd } if [ ! -d "$OPENSSL_DIR" ]; then echo "OpenSSL sources not found. Downloading." download_openssl fi if [ ! -f "$OPENSSL_LIBCRYPTO" ]; then echo "OpenSSL binary not found. Building." build_openssl fi download_sqlite() { mkdir -p "$SQLITE_DIR" curl "${SQLCIPHER_AMALGAMATION_URL}" --output "${SQLCIPHER_AMALGAMATION_FILE}" unzip -jo "${SQLCIPHER_AMALGAMATION_FILE}" -d "${SQLITE_DIR}" rm -f "${SQLCIPHER_AMALGAMATION_FILE}" } if [ ! -f "$SQLITE_BITCODE_FILE" ]; then echo "SQLite engine not found. Downloading." download_sqlite emcc "${SQLITE_COMPILATION_FLAGS[@]}" \ -I "${OPENSSL_HEADERS}" \ -c "$SQLITE_SOURCE" \ -o "$SQLITE_BITCODE_FILE" fi EMCC_FLAGS=( # WASM files and bindings --memory-init-file 0 -s WASM=1 -s ALLOW_MEMORY_GROWTH=1 -s ALLOW_TABLE_GROWTH=1 -s FORCE_FILESYSTEM=1 -s SINGLE_FILE=0 -s EXPORTED_RUNTIME_METHODS=["FS"] # node/babel/webpack helpers -s NODEJS_CATCH_EXIT=0 -s NODEJS_CATCH_REJECTION=0 -s WASM_ASYNC_COMPILATION=0 -s EXPORT_ES6=1 -s USE_ES6_IMPORT_META=0 -s MODULARIZE=1 # optimization -Oz -flto --closure 1 ) CFLAGS=( -I "$INPUT_DIR" -I "$SQLITE_DIR" -I "${NATIVE_CPP_DIR}CommonCpp/Tools/" ) INPUT_FILES=( "${INPUT_DIR}SQLiteConnectionManager.cpp" "${WEB_CPP_DIR}SQLiteQueryExecutorBindings.cpp" "${WEB_CPP_DIR}Logger.cpp" "${ENTITIES_DIR}SQLiteDataConverters.cpp" "${ENTITIES_DIR}SQLiteStatementWrapper.cpp" "$SQLITE_BITCODE_FILE" ) mkdir -p "$OUTPUT_DIR" emcc -lembind \ "${EMCC_FLAGS[@]}" \ "${CFLAGS[@]}" \ "${INPUT_FILES[@]}" \ "${OPENSSL_LIBCRYPTO}" \ -o "${OUTPUT_FILE}" \ -std=c++17 GENERATED_TAG="generated" sed -i.bak -e "1i\/\/ \@${GENERATED_TAG}" "${OUTPUT_FILE}" mv -f "${OUTPUT_DIR}${OUTPUT_FILE_NAME}.wasm" "${OUTPUT_DIR}comm_query_executor.wasm" rm -f "${OUTPUT_FILE}.bak" diff --git a/web/settings/backup-test-restore-modal.react.js b/web/settings/backup-test-restore-modal.react.js index bc77c5680..9c0916266 100644 --- a/web/settings/backup-test-restore-modal.react.js +++ b/web/settings/backup-test-restore-modal.react.js @@ -1,118 +1,118 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import css from './backup-test-restore-modal.css'; import Button from '../components/button.react.js'; -import { getDatabaseModule } from '../database/database-module-provider.js'; import Input from '../modals/input.react.js'; import Modal from '../modals/modal.react.js'; +import { getCommSharedWorker } from '../shared-worker/shared-worker-provider.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; type Props = { +onClose: () => void, }; function BackupTestRestoreModal(props: Props): React.Node { const { onClose } = props; const [backupID, setBackupID] = React.useState(''); const [backupDataKey, setBackupDataKey] = React.useState(''); const [backupLogDataKey, setBackupLogDataKey] = React.useState(''); const [inProgress, setInProgress] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); const client = React.useContext(IdentityClientContext); const onSubmit = React.useCallback( async (event: SyntheticEvent) => { event.preventDefault(); setInProgress(true); void (async () => { try { if (!client) { throw new Error('No identity client'); } const authMetadata = await client.getAuthMetadata(); - const databaseModule = await getDatabaseModule(); - await databaseModule.schedule({ + const sharedWorker = await getCommSharedWorker(); + await sharedWorker.schedule({ type: workerRequestMessageTypes.BACKUP_RESTORE, authMetadata, backupID, backupDataKey, backupLogDataKey, }); } catch (e) { setErrorMessage(e.message); } setInProgress(false); })(); }, [backupDataKey, backupID, backupLogDataKey, client], ); let errorMsg; if (errorMessage) { errorMsg =
{errorMessage}
; } return (
) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); setBackupID(target.value); }} disabled={inProgress} label="Backup ID" /> ) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); setBackupDataKey(target.value); }} disabled={inProgress} label="Backup Data Encryption Key" /> ) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); setBackupLogDataKey(target.value); }} disabled={inProgress} label="Backup Logs Encryption Key" />
{errorMsg}
); } export default BackupTestRestoreModal; diff --git a/web/database/_generated/comm-query-executor.js b/web/shared-worker/_generated/comm-query-executor.js similarity index 100% rename from web/database/_generated/comm-query-executor.js rename to web/shared-worker/_generated/comm-query-executor.js diff --git a/web/database/_generated/comm_query_executor.wasm b/web/shared-worker/_generated/comm_query_executor.wasm similarity index 100% rename from web/database/_generated/comm_query_executor.wasm rename to web/shared-worker/_generated/comm_query_executor.wasm diff --git a/web/database/db-module.js b/web/shared-worker/db-module.js similarity index 100% rename from web/database/db-module.js rename to web/shared-worker/db-module.js diff --git a/web/database/db-module.test.js b/web/shared-worker/db-module.test.js similarity index 100% rename from web/database/db-module.test.js rename to web/shared-worker/db-module.test.js diff --git a/web/database/queries/communities-queries.test.js b/web/shared-worker/queries/communities-queries.test.js similarity index 100% rename from web/database/queries/communities-queries.test.js rename to web/shared-worker/queries/communities-queries.test.js diff --git a/web/database/queries/draft-queries.test.js b/web/shared-worker/queries/draft-queries.test.js similarity index 100% rename from web/database/queries/draft-queries.test.js rename to web/shared-worker/queries/draft-queries.test.js diff --git a/web/database/queries/keyservers-queries.test.js b/web/shared-worker/queries/keyservers-queries.test.js similarity index 100% rename from web/database/queries/keyservers-queries.test.js rename to web/shared-worker/queries/keyservers-queries.test.js diff --git a/web/database/queries/message-store-threads-queries.test.js b/web/shared-worker/queries/message-store-threads-queries.test.js similarity index 100% rename from web/database/queries/message-store-threads-queries.test.js rename to web/shared-worker/queries/message-store-threads-queries.test.js diff --git a/web/database/queries/message-to-device-queries.test.js b/web/shared-worker/queries/message-to-device-queries.test.js similarity index 100% rename from web/database/queries/message-to-device-queries.test.js rename to web/shared-worker/queries/message-to-device-queries.test.js diff --git a/web/database/queries/messages-and-media-queries.test.js b/web/shared-worker/queries/messages-and-media-queries.test.js similarity index 100% rename from web/database/queries/messages-and-media-queries.test.js rename to web/shared-worker/queries/messages-and-media-queries.test.js diff --git a/web/database/queries/metadata-queries.test.js b/web/shared-worker/queries/metadata-queries.test.js similarity index 100% rename from web/database/queries/metadata-queries.test.js rename to web/shared-worker/queries/metadata-queries.test.js diff --git a/web/database/queries/olm-persist-data-queries.test.js b/web/shared-worker/queries/olm-persist-data-queries.test.js similarity index 100% rename from web/database/queries/olm-persist-data-queries.test.js rename to web/shared-worker/queries/olm-persist-data-queries.test.js diff --git a/web/database/queries/report-queries.test.js b/web/shared-worker/queries/report-queries.test.js similarity index 100% rename from web/database/queries/report-queries.test.js rename to web/shared-worker/queries/report-queries.test.js diff --git a/web/database/queries/storage-engine-queries.test.js b/web/shared-worker/queries/storage-engine-queries.test.js similarity index 100% rename from web/database/queries/storage-engine-queries.test.js rename to web/shared-worker/queries/storage-engine-queries.test.js diff --git a/web/database/queries/threads-queries.test.js b/web/shared-worker/queries/threads-queries.test.js similarity index 100% rename from web/database/queries/threads-queries.test.js rename to web/shared-worker/queries/threads-queries.test.js diff --git a/web/database/queries/user-quries.test.js b/web/shared-worker/queries/user-quries.test.js similarity index 100% rename from web/database/queries/user-quries.test.js rename to web/shared-worker/queries/user-quries.test.js diff --git a/web/database/database-module-provider.js b/web/shared-worker/shared-worker-provider.js similarity index 71% rename from web/database/database-module-provider.js rename to web/shared-worker/shared-worker-provider.js index 8734f9d02..c68e74163 100644 --- a/web/database/database-module-provider.js +++ b/web/shared-worker/shared-worker-provider.js @@ -1,165 +1,169 @@ // @flow import invariant from 'invariant'; import localforage from 'localforage'; import { getConfig } from 'lib/utils/config.js'; import { DATABASE_WORKER_PATH, WORKERS_MODULES_DIR_PATH, SQLITE_ENCRYPTION_KEY, } from './utils/constants.js'; import { isDesktopSafari, isSQLiteSupported } from './utils/db-utils.js'; import WorkerConnectionProxy from './utils/worker-connection-proxy.js'; import { exportKeyToJWK, generateCryptoKey, } from '../crypto/aes-gcm-crypto-utils.js'; import { workerRequestMessageTypes, type WorkerRequestMessage, type WorkerResponseMessage, } from '../types/worker-types.js'; declare var baseURL: string; declare var commQueryExecutorFilename: string; declare var backupClientFilename: string; -const databaseStatuses = Object.freeze({ +const sharedWorkerStatuses = Object.freeze({ notRunning: 'NOT_RUNNING', initSuccess: 'INIT_SUCCESS', initInProgress: 'INIT_IN_PROGRESS', initError: 'INIT_ERROR', }); -type DatabaseStatus = +type SharedWorkerStatus = | { +type: 'NOT_RUNNING' | 'INIT_SUCCESS' | 'INIT_ERROR' } | { +type: 'INIT_IN_PROGRESS', +initPromise: Promise }; type InitOptions = { +clearDatabase: boolean }; -class DatabaseModule { +class CommSharedWorker { worker: ?SharedWorker; workerProxy: ?WorkerConnectionProxy; - status: DatabaseStatus = { type: databaseStatuses.notRunning }; + status: SharedWorkerStatus = { type: sharedWorkerStatuses.notRunning }; async init({ clearDatabase }: InitOptions): Promise { if (!isSQLiteSupported()) { console.warn('SQLite is not supported'); - this.status = { type: databaseStatuses.initError }; + this.status = { type: sharedWorkerStatuses.initError }; return; } - if (this.status.type === databaseStatuses.initInProgress) { + if (this.status.type === sharedWorkerStatuses.initInProgress) { await this.status.initPromise; return; } if ( - (this.status.type === databaseStatuses.initSuccess && !clearDatabase) || - this.status.type === databaseStatuses.initError + (this.status.type === sharedWorkerStatuses.initSuccess && + !clearDatabase) || + this.status.type === sharedWorkerStatuses.initError ) { return; } const initPromise = (async () => { - if (clearDatabase && this.status.type === databaseStatuses.initSuccess) { + if ( + clearDatabase && + this.status.type === sharedWorkerStatuses.initSuccess + ) { console.info('Clearing sensitive data'); invariant(this.workerProxy, 'Worker proxy should exist'); await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.CLEAR_SENSITIVE_DATA, }); } const codeVersion = getConfig().platformDetails.codeVersion ?? ''; - const workerName = `comm-app-database-${codeVersion}`; + const workerName = `comm-app-shared-worker-${codeVersion}`; this.worker = new SharedWorker(DATABASE_WORKER_PATH, workerName); this.worker.onerror = console.error; this.workerProxy = new WorkerConnectionProxy( this.worker.port, console.error, ); const origin = window.location.origin; try { let encryptionKey = null; if (isDesktopSafari) { encryptionKey = await getSafariEncryptionKey(); } invariant(this.workerProxy, 'Worker proxy should exist'); await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.INIT, webworkerModulesFilePath: `${origin}${baseURL}${WORKERS_MODULES_DIR_PATH}`, encryptionKey, commQueryExecutorFilename, backupClientFilename, }); - this.status = { type: databaseStatuses.initSuccess }; + this.status = { type: sharedWorkerStatuses.initSuccess }; console.info('Database initialization success'); } catch (error) { - this.status = { type: databaseStatuses.initError }; + this.status = { type: sharedWorkerStatuses.initError }; console.error(`Database initialization failure`, error); } })(); - this.status = { type: databaseStatuses.initInProgress, initPromise }; + this.status = { type: sharedWorkerStatuses.initInProgress, initPromise }; await initPromise; } - async isDatabaseSupported(): Promise { - if (this.status.type === databaseStatuses.initInProgress) { + async isSupported(): Promise { + if (this.status.type === sharedWorkerStatuses.initInProgress) { await this.status.initPromise; } - return this.status.type === databaseStatuses.initSuccess; + return this.status.type === sharedWorkerStatuses.initSuccess; } async schedule( payload: WorkerRequestMessage, ): Promise { - if (this.status.type === databaseStatuses.notRunning) { + if (this.status.type === sharedWorkerStatuses.notRunning) { throw new Error('Database not running'); } - if (this.status.type === databaseStatuses.initInProgress) { + if (this.status.type === sharedWorkerStatuses.initInProgress) { await this.status.initPromise; } - if (this.status.type === databaseStatuses.initError) { + if (this.status.type === sharedWorkerStatuses.initError) { throw new Error('Database could not be initialized'); } invariant(this.workerProxy, 'Worker proxy should exist'); return this.workerProxy.scheduleOnWorker(payload); } } async function getSafariEncryptionKey(): Promise { const encryptionKey = await localforage.getItem( SQLITE_ENCRYPTION_KEY, ); if (encryptionKey) { return await exportKeyToJWK(encryptionKey); } const newEncryptionKey = await generateCryptoKey({ extractable: true, }); await localforage.setItem(SQLITE_ENCRYPTION_KEY, newEncryptionKey); return await exportKeyToJWK(newEncryptionKey); } -let databaseModule: ?DatabaseModule = null; -async function getDatabaseModule(): Promise { - if (databaseModule) { - return databaseModule; +let sharedWorker: ?CommSharedWorker = null; +async function getCommSharedWorker(): Promise { + if (sharedWorker) { + return sharedWorker; } - const newModule = new DatabaseModule(); - databaseModule = newModule; + const newModule = new CommSharedWorker(); + sharedWorker = newModule; await newModule.init({ clearDatabase: false }); return newModule; } -export { getDatabaseModule }; +export { getCommSharedWorker }; diff --git a/web/database/sqlite-data-handler.js b/web/shared-worker/sqlite-data-handler.js similarity index 84% rename from web/database/sqlite-data-handler.js rename to web/shared-worker/sqlite-data-handler.js index 05592cb10..daffa6e4a 100644 --- a/web/database/sqlite-data-handler.js +++ b/web/shared-worker/sqlite-data-handler.js @@ -1,91 +1,91 @@ // @flow import * as React from 'react'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; -import { getDatabaseModule } from './database-module-provider.js'; +import { getCommSharedWorker } from './shared-worker-provider.js'; import { useSelector } from '../redux/redux-utils.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; function SQLiteDataHandler(): React.Node { const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); const handleSensitiveData = React.useCallback(async () => { - const databaseModule = await getDatabaseModule(); + const sharedWorker = await getCommSharedWorker(); let currentDBUserID, errorGettingUserID = false; try { - const currentUserData = await databaseModule.schedule({ + const currentUserData = await sharedWorker.schedule({ type: workerRequestMessageTypes.GET_CURRENT_USER_ID, }); currentDBUserID = currentUserData?.userID; } catch (error) { errorGettingUserID = true; console.error( `Error setting current user ID: ${ getMessageForException(error) ?? 'unknown' }`, ); } if (currentDBUserID === currentLoggedInUserID && !errorGettingUserID) { return; } if (currentDBUserID || errorGettingUserID) { try { - await databaseModule.init({ clearDatabase: true }); + await sharedWorker.init({ clearDatabase: true }); } catch (error) { console.error( `Error clearing sensitive data: ${ getMessageForException(error) ?? 'unknown' }`, ); // We return here to avoid assigning new user to old data return; } } if (currentLoggedInUserID) { try { - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.SET_CURRENT_USER_ID, userID: currentLoggedInUserID, }); } catch (error) { console.error( `Error setting current user ID: ${ getMessageForException(error) ?? 'unknown' }`, ); } } }, [currentLoggedInUserID]); React.useEffect(() => { void (async () => { - const databaseModule = await getDatabaseModule(); + const sharedWorker = await getCommSharedWorker(); if (!rehydrateConcluded) { return; } - const isSupported = await databaseModule.isDatabaseSupported(); + const isSupported = await sharedWorker.isSupported(); if (!isSupported) { return; } await handleSensitiveData(); })(); }, [dispatch, handleSensitiveData, rehydrateConcluded]); return null; } export { SQLiteDataHandler }; diff --git a/web/database/types/entities.js b/web/shared-worker/types/entities.js similarity index 100% rename from web/database/types/entities.js rename to web/shared-worker/types/entities.js diff --git a/web/database/types/entities.test.js b/web/shared-worker/types/entities.test.js similarity index 100% rename from web/database/types/entities.test.js rename to web/shared-worker/types/entities.test.js diff --git a/web/database/types/file-system.js b/web/shared-worker/types/file-system.js similarity index 100% rename from web/database/types/file-system.js rename to web/shared-worker/types/file-system.js diff --git a/web/database/types/module.js b/web/shared-worker/types/module.js similarity index 100% rename from web/database/types/module.js rename to web/shared-worker/types/module.js diff --git a/web/database/types/sqlite-query-executor.js b/web/shared-worker/types/sqlite-query-executor.js similarity index 100% rename from web/database/types/sqlite-query-executor.js rename to web/shared-worker/types/sqlite-query-executor.js diff --git a/web/database/utils/constants.js b/web/shared-worker/utils/constants.js similarity index 100% rename from web/database/utils/constants.js rename to web/shared-worker/utils/constants.js diff --git a/web/database/utils/db-utils.js b/web/shared-worker/utils/db-utils.js similarity index 100% rename from web/database/utils/db-utils.js rename to web/shared-worker/utils/db-utils.js diff --git a/web/database/utils/store.js b/web/shared-worker/utils/store.js similarity index 90% rename from web/database/utils/store.js rename to web/shared-worker/utils/store.js index e31483ec3..1403c74f9 100644 --- a/web/database/utils/store.js +++ b/web/shared-worker/utils/store.js @@ -1,136 +1,136 @@ // @flow import { communityStoreOpsHandlers } from 'lib/ops/community-store-ops.js'; import { keyserverStoreOpsHandlers } from 'lib/ops/keyserver-store-ops.js'; import { reportStoreOpsHandlers } from 'lib/ops/report-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import type { ClientStore, StoreOperations, } from 'lib/types/store-ops-types.js'; import { defaultWebState } from '../../redux/default-state.js'; import { workerRequestMessageTypes } from '../../types/worker-types.js'; -import { getDatabaseModule } from '../database-module-provider.js'; +import { getCommSharedWorker } from '../shared-worker-provider.js'; async function getClientDBStore(): Promise { - const databaseModule = await getDatabaseModule(); + const sharedWorker = await getCommSharedWorker(); let result: ClientStore = { currentUserID: null, drafts: [], messages: null, threadStore: null, messageStoreThreads: null, reports: null, users: null, keyserverInfos: defaultWebState.keyserverStore.keyserverInfos, communityInfos: null, }; - const data = await databaseModule.schedule({ + const data = await sharedWorker.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); if (data?.store?.drafts) { result = { ...result, drafts: data.store.drafts, }; } if (data?.store?.reports) { result = { ...result, reports: reportStoreOpsHandlers.translateClientDBData(data.store.reports), }; } if (data?.store?.threads && data.store.threads.length > 0) { result = { ...result, threadStore: { threadInfos: threadStoreOpsHandlers.translateClientDBData( data.store.threads, ), }, }; } if (data?.store?.keyservers?.length) { result = { ...result, keyserverInfos: keyserverStoreOpsHandlers.translateClientDBData( data.store.keyservers, ), }; } if (data?.store?.communities) { result = { ...result, communityInfos: communityStoreOpsHandlers.translateClientDBData( data.store.communities, ), }; } return result; } async function processDBStoreOperations( storeOperations: StoreOperations, userID: null | string, ): Promise { const { draftStoreOperations, threadStoreOperations, reportStoreOperations, keyserverStoreOperations, communityStoreOperations, } = storeOperations; const canUseDatabase = canUseDatabaseOnWeb(userID); const convertedThreadStoreOperations = canUseDatabase ? threadStoreOpsHandlers.convertOpsToClientDBOps(threadStoreOperations) : []; const convertedReportStoreOperations = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); const convertedKeyserverStoreOperations = keyserverStoreOpsHandlers.convertOpsToClientDBOps(keyserverStoreOperations); const convertedCommunityStoreOperations = communityStoreOpsHandlers.convertOpsToClientDBOps(communityStoreOperations); if ( convertedThreadStoreOperations.length === 0 && convertedReportStoreOperations.length === 0 && draftStoreOperations.length === 0 && convertedKeyserverStoreOperations.length === 0 && convertedCommunityStoreOperations.length === 0 ) { return; } - const databaseModule = await getDatabaseModule(); - const isSupported = await databaseModule.isDatabaseSupported(); + const sharedWorker = await getCommSharedWorker(); + const isSupported = await sharedWorker.isSupported(); if (!isSupported) { return; } try { - await databaseModule.schedule({ + await sharedWorker.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations, reportStoreOperations: convertedReportStoreOperations, threadStoreOperations: convertedThreadStoreOperations, keyserverStoreOperations: convertedKeyserverStoreOperations, communityStoreOperations: convertedCommunityStoreOperations, }, }); } catch (e) { console.log(e); if (canUseDatabase) { window.alert(e.message); if (threadStoreOperations.length > 0) { - await databaseModule.init({ clearDatabase: true }); + await sharedWorker.init({ clearDatabase: true }); location.reload(); } } } } export { getClientDBStore, processDBStoreOperations }; diff --git a/web/database/utils/worker-connection-proxy.js b/web/shared-worker/utils/worker-connection-proxy.js similarity index 100% rename from web/database/utils/worker-connection-proxy.js rename to web/shared-worker/utils/worker-connection-proxy.js diff --git a/web/database/utils/worker-crypto-utils.test.js b/web/shared-worker/utils/worker-crypto-utils.test.js similarity index 100% rename from web/database/utils/worker-crypto-utils.test.js rename to web/shared-worker/utils/worker-crypto-utils.test.js diff --git a/web/database/worker/backup.js b/web/shared-worker/worker/backup.js similarity index 100% rename from web/database/worker/backup.js rename to web/shared-worker/worker/backup.js diff --git a/web/database/worker/process-operations.js b/web/shared-worker/worker/process-operations.js similarity index 100% rename from web/database/worker/process-operations.js rename to web/shared-worker/worker/process-operations.js diff --git a/web/database/worker/db-worker.js b/web/shared-worker/worker/shared-worker.js similarity index 100% rename from web/database/worker/db-worker.js rename to web/shared-worker/worker/shared-worker.js diff --git a/web/webpack.config.cjs b/web/webpack.config.cjs index e80c17b72..e0a490a80 100644 --- a/web/webpack.config.cjs +++ b/web/webpack.config.cjs @@ -1,257 +1,257 @@ const CopyPlugin = require('copy-webpack-plugin'); const path = require('path'); const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); const { createProdBrowserConfig, createDevBrowserConfig, createNodeServerRenderingConfig, createWebWorkersConfig, } = require('lib/webpack/shared.cjs'); const babelConfig = require('./.babelrc.cjs'); async function getConfig(configName) { const { getCommConfig } = await import( 'keyserver/dist/lib/utils/comm-config.js' ); return await getCommConfig(configName); } const baseBrowserConfig = { entry: { browser: ['./script.js'], }, output: { filename: 'prod.[contenthash:12].build.js', path: path.join(__dirname, 'dist'), }, resolve: { alias: { '../images': path.resolve('../keyserver/images'), }, fallback: { crypto: false, fs: false, path: false, }, }, }; const baseDevBrowserConfig = { ...baseBrowserConfig, output: { ...baseBrowserConfig.output, filename: 'dev.build.js', pathinfo: true, publicPath: 'http://localhost:8080/', }, devServer: { port: 8080, headers: { 'Access-Control-Allow-Origin': '*' }, allowedHosts: ['all'], host: '0.0.0.0', static: { directory: path.join(__dirname, 'dist'), }, }, plugins: [ new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/olm/olm.wasm', to: path.join(__dirname, 'dist'), }, ], }), new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/opaque-ke-wasm' + '/pkg/comm_opaque2_wasm_bg.wasm', to: path.join(__dirname, 'dist', 'opaque-ke.wasm'), }, ], }), ], }; const baseProdBrowserConfig = { ...baseBrowserConfig, plugins: [ new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/olm/olm.wasm', to: path.join(__dirname, 'dist', 'olm.[contenthash:12].wasm'), }, ], }), new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/opaque-ke-wasm' + '/pkg/comm_opaque2_wasm_bg.wasm', to: path.join(__dirname, 'dist', 'opaque-ke.[contenthash:12].wasm'), }, ], }), new WebpackManifestPlugin({ publicPath: '', }), ], }; const baseNodeServerRenderingConfig = { externals: ['react', 'react-dom', 'react-redux'], entry: { keyserver: ['./loading.react.js'], }, output: { filename: 'app.build.cjs', library: 'app', libraryTarget: 'commonjs2', path: path.join(__dirname, 'dist'), }, }; const baseWebWorkersConfig = { entry: { pushNotif: './push-notif/service-worker.js', - database: './database/worker/db-worker.js', + database: './shared-worker/worker/shared-worker.js', }, output: { filename: '[name].build.js', path: path.join(__dirname, 'dist', 'webworkers'), }, resolve: { fallback: { crypto: false, fs: false, path: false, }, }, }; const devWebWorkersPlugins = [ new CopyPlugin({ patterns: [ { - from: 'database/_generated/comm_query_executor.wasm', + from: 'shared-worker/_generated/comm_query_executor.wasm', to: path.join(__dirname, 'dist', 'webworkers'), }, ], }), new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/olm/olm.wasm', to: path.join(__dirname, 'dist', 'webworkers'), }, ], }), new CopyPlugin({ patterns: [ { from: 'backup-client-wasm/wasm/backup-client-wasm_bg.wasm', to: path.join(__dirname, 'dist', 'webworkers'), }, ], }), ]; const prodWebWorkersPlugins = [ new CopyPlugin({ patterns: [ { - from: 'database/_generated/comm_query_executor.wasm', + from: 'shared-worker/_generated/comm_query_executor.wasm', to: path.join( __dirname, 'dist', 'webworkers', 'comm_query_executor.[contenthash:12].wasm', ), }, ], }), new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/olm/olm.wasm', to: path.join( __dirname, 'dist', 'webworkers', 'olm.[contenthash:12].wasm', ), }, ], }), new CopyPlugin({ patterns: [ { from: 'backup-client-wasm/wasm/backup-client-wasm_bg.wasm', to: path.join( __dirname, 'dist', 'webworkers', 'backup-client.[contenthash:12].wasm', ), }, ], }), new WebpackManifestPlugin({ publicPath: '', }), ]; module.exports = async function (env) { const identityServiceConfig = await getConfig({ folder: 'secrets', name: 'identity_service_config', }); const identitySocketAddr = JSON.stringify( identityServiceConfig?.identitySocketAddr, ); const authoritativeKeyserverIDConfig = await getConfig({ folder: 'facts', name: 'authoritative_keyserver', }); const authoritativeKeyserverID = JSON.stringify( authoritativeKeyserverIDConfig?.authoritativeKeyserverID, ); const envVars = { IDENTITY_SOCKET_ADDR: identitySocketAddr, AUTHORITATIVE_KEYSERVER_ID: authoritativeKeyserverID, }; const browserConfigPromise = env.prod ? createProdBrowserConfig(baseProdBrowserConfig, babelConfig, envVars) : createDevBrowserConfig(baseDevBrowserConfig, babelConfig, envVars); const nodeConfigPromise = createNodeServerRenderingConfig( baseNodeServerRenderingConfig, babelConfig, ); const [browserConfig, nodeConfig] = await Promise.all([ browserConfigPromise, nodeConfigPromise, ]); const nodeServerRenderingConfig = { ...nodeConfig, mode: env.prod ? 'production' : 'development', }; const workersConfig = { ...baseWebWorkersConfig, plugins: env.prod ? prodWebWorkersPlugins : devWebWorkersPlugins, }; const webWorkersConfig = createWebWorkersConfig( env, workersConfig, babelConfig, ); return [browserConfig, nodeServerRenderingConfig, webWorkersConfig]; };