diff --git a/lib/selectors/account-selectors.js b/lib/selectors/account-selectors.js index f50fe134f..bdae03004 100644 --- a/lib/selectors/account-selectors.js +++ b/lib/selectors/account-selectors.js @@ -1,50 +1,50 @@ // @flow import { createSelector } from 'reselect'; -import { cookieSelector } from './keyserver-selectors.js'; +import { cookieSelector, sessionIDSelector } from './keyserver-selectors.js'; import { currentCalendarQuery } from './nav-selectors.js'; import type { LogInExtraInfo } from '../types/account-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { AppState } from '../types/redux-types.js'; import type { PreRequestUserState } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; const logInExtraInfoSelector: ( state: AppState, ) => (calendarActive: boolean) => LogInExtraInfo = createSelector( (state: AppState) => state.deviceToken, currentCalendarQuery, ( deviceToken: ?string, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => { let deviceTokenUpdateRequest = null; if (deviceToken) { deviceTokenUpdateRequest = { deviceToken }; } // Return a function since we depend on the time of evaluation return (calendarActive: boolean): LogInExtraInfo => ({ calendarQuery: calendarQuery(calendarActive), deviceTokenUpdateRequest, }); }, ); const preRequestUserStateSelector: (state: AppState) => PreRequestUserState = createSelector( (state: AppState) => state.currentUserInfo, cookieSelector, - (state: AppState) => state.sessionID, + sessionIDSelector, ( currentUserInfo: ?CurrentUserInfo, cookie: ?string, sessionID: ?string, ) => ({ currentUserInfo, cookie, sessionID, }), ); export { logInExtraInfoSelector, preRequestUserStateSelector }; diff --git a/lib/selectors/keyserver-selectors.js b/lib/selectors/keyserver-selectors.js index 8c4f3feb3..373d0bc3c 100644 --- a/lib/selectors/keyserver-selectors.js +++ b/lib/selectors/keyserver-selectors.js @@ -1,25 +1,28 @@ // @flow import { createSelector } from 'reselect'; import type { KeyserverInfo } from '../types/keyserver-types'; import type { AppState } from '../types/redux-types.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const cookieSelector: (state: AppState) => ?string = (state: AppState) => state.keyserverStore.keyserverInfos[ashoatKeyserverID]?.cookie; const cookiesSelector: (state: AppState) => { +[keyserverID: string]: string, } = createSelector( (state: AppState) => state.keyserverStore.keyserverInfos, (infos: { +[key: string]: KeyserverInfo }) => { const cookies = {}; for (const keyserverID in infos) { cookies[keyserverID] = infos[keyserverID].cookie; } return cookies; }, ); -export { cookieSelector, cookiesSelector }; +const sessionIDSelector: (state: AppState) => ?string = (state: AppState) => + state.keyserverStore.keyserverInfos[ashoatKeyserverID]?.sessionID; + +export { cookieSelector, cookiesSelector, sessionIDSelector }; diff --git a/lib/selectors/server-calls.js b/lib/selectors/server-calls.js index 293c99dba..30fb8c47c 100644 --- a/lib/selectors/server-calls.js +++ b/lib/selectors/server-calls.js @@ -1,45 +1,45 @@ // @flow import { createSelector } from 'reselect'; -import { cookieSelector } from './keyserver-selectors.js'; +import { cookieSelector, sessionIDSelector } from './keyserver-selectors.js'; import type { LastCommunicatedPlatformDetails } from '../types/device-types.js'; import type { AppState } from '../types/redux-types.js'; import { type ConnectionStatus } from '../types/socket-types.js'; import { type CurrentUserInfo } from '../types/user-types.js'; export type ServerCallState = { +cookie: ?string, +urlPrefix: string, +sessionID: ?string, +currentUserInfo: ?CurrentUserInfo, +connectionStatus: ConnectionStatus, +lastCommunicatedPlatformDetails: LastCommunicatedPlatformDetails, }; const serverCallStateSelector: (state: AppState) => ServerCallState = createSelector( cookieSelector, (state: AppState) => state.urlPrefix, - (state: AppState) => state.sessionID, + sessionIDSelector, (state: AppState) => state.currentUserInfo, (state: AppState) => state.connection.status, (state: AppState) => state.lastCommunicatedPlatformDetails, ( cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, lastCommunicatedPlatformDetails: LastCommunicatedPlatformDetails, ) => ({ cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, lastCommunicatedPlatformDetails, }), ); export { serverCallStateSelector }; diff --git a/lib/shared/session-utils.js b/lib/shared/session-utils.js index b6040f12c..1f4c1d09f 100644 --- a/lib/shared/session-utils.js +++ b/lib/shared/session-utils.js @@ -1,57 +1,60 @@ // @flow -import { cookieSelector } from '../selectors/keyserver-selectors.js'; +import { + cookieSelector, + sessionIDSelector, +} from '../selectors/keyserver-selectors.js'; import { logInActionSources, type LogInActionSource, } from '../types/account-types.js'; import type { AppState } from '../types/redux-types.js'; import type { PreRequestUserState } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; function invalidSessionDowngrade( currentReduxState: AppState, actionCurrentUserInfo: ?CurrentUserInfo, preRequestUserState: ?PreRequestUserState, ): boolean { // If this action represents a session downgrade - oldState has a loggedIn // currentUserInfo, but the action has an anonymous one - then it is only // valid if the currentUserInfo used for the request matches what oldState // currently has. If the currentUserInfo in Redux has changed since the // request, and is currently loggedIn, then the session downgrade does not // apply to it. In this case we will simply swallow the action. const currentCurrentUserInfo = currentReduxState.currentUserInfo; return !!( currentCurrentUserInfo && !currentCurrentUserInfo.anonymous && // Note that an undefined actionCurrentUserInfo represents an action that // doesn't affect currentUserInfo, whereas a null one represents an action // that sets it to null (actionCurrentUserInfo === null || (actionCurrentUserInfo && actionCurrentUserInfo.anonymous)) && preRequestUserState && (preRequestUserState.currentUserInfo?.id !== currentCurrentUserInfo.id || preRequestUserState.cookie !== cookieSelector(currentReduxState) || - preRequestUserState.sessionID !== currentReduxState.sessionID) + preRequestUserState.sessionID !== sessionIDSelector(currentReduxState)) ); } function invalidSessionRecovery( currentReduxState: AppState, actionCurrentUserInfo: CurrentUserInfo, logInActionSource: ?LogInActionSource, ): boolean { if ( logInActionSource !== logInActionSources.cookieInvalidationResolutionAttempt && logInActionSource !== logInActionSources.socketAuthErrorResolutionAttempt ) { return false; } return ( !currentReduxState.dataLoaded || currentReduxState.currentUserInfo?.id !== actionCurrentUserInfo.id ); } export { invalidSessionDowngrade, invalidSessionRecovery }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 72388e3d7..16b1cbd44 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,319 +1,331 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/es/types.js'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions.js'; import { reportStoreOpsHandlers } from 'lib/ops/report-store-ops.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 } from 'lib/shared/session-utils.js'; import type { Shape } from 'lib/types/core.js'; import type { CryptoStore, OLMIdentityKeys, PickledOLMAccount, } from 'lib/types/crypto-types.js'; import type { LastCommunicatedPlatformDetails } from 'lib/types/device-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 { 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 { 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 { ConnectionInfo } from 'lib/types/socket-types.js'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types.js'; import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import type { NotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; +import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateWindowActiveActionType, setDeviceIDActionType, updateNavInfoActionType, updateWindowDimensionsActionType, } from './action-types.js'; import { reduceCommunityPickerStore } from './community-picker-reducer.js'; import { reduceCryptoStore, setPrimaryIdentityKeys, setNotificationIdentityKeys, setPickledNotificationAccount, setPickledPrimaryAccount, } from './crypto-store-reducer.js'; import { reduceDeviceID } from './device-id-reducer.js'; import reduceNavInfo from './nav-reducer.js'; import { getVisibility } from './visibility.js'; import { databaseModule } from '../database/database-module-provider.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; import { type NavInfo } from '../types/nav-types.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; export type WindowDimensions = { width: number, height: number }; export type CommunityPickerStore = { +chat: ?string, +calendar: ?string, }; export type AppState = { +navInfo: NavInfo, +deviceID: ?string, +currentUserInfo: ?CurrentUserInfo, +draftStore: DraftStore, +sessionID: ?string, +entryStore: EntryStore, +threadStore: ThreadStore, +userStore: UserStore, +messageStore: MessageStore, +updatesCurrentAsOf: number, +loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, +calendarFilters: $ReadOnlyArray, +communityPickerStore: CommunityPickerStore, +urlPrefix: string, +windowDimensions: WindowDimensions, +deviceToken: ?string, +baseHref: string, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +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, +lastCommunicatedPlatformDetails: LastCommunicatedPlatformDetails, +keyserverStore: KeyserverStore, }; export type Action = | BaseAction | { type: 'UPDATE_NAV_INFO', payload: Shape } | { type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, } | { type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, } | { type: 'SET_DEVICE_ID', payload: string, } | { +type: 'SET_PRIMARY_IDENTITY_KEYS', payload: ?OLMIdentityKeys } | { +type: 'SET_NOTIFICATION_IDENTITY_KEYS', payload: ?OLMIdentityKeys } | { +type: 'SET_PICKLED_PRIMARY_ACCOUNT', payload: ?PickledOLMAccount } | { +type: 'SET_PICKLED_NOTIFICATION_ACCOUNT', payload: ?PickledOLMAccount }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateWindowDimensionsActionType) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } + state = { ...state, sessionID: action.payload.sessionChange.sessionID, + keyserverStore: { + ...state.keyserverStore, + keyserverInfos: { + ...state.keyserverStore.keyserverInfos, + [ashoatKeyserverID]: { + ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], + sessionID: action.payload.sessionChange.sessionID, + }, + }, + }, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } if ( action.type !== updateNavInfoActionType && action.type !== setDeviceIDActionType && action.type !== setPrimaryIdentityKeys && action.type !== setNotificationIdentityKeys && action.type !== setPickledPrimaryAccount && action.type !== setPickledNotificationAccount ) { const baseReducerResult = baseReducer(state, action); state = baseReducerResult.state; const { storeOperations: { draftStoreOperations, reportStoreOperations }, } = baseReducerResult; if (draftStoreOperations.length > 0 || reportStoreOperations.length > 0) { (async () => { const isSupported = await databaseModule.isDatabaseSupported(); if (!isSupported) { return; } const convertedReportStoreOperations = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); await databaseModule.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations, reportStoreOperations: convertedReportStoreOperations, }, }); })(); } } const communityPickerStore = reduceCommunityPickerStore( state.communityPickerStore, action, ); state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), deviceID: reduceDeviceID(state.deviceID, action), cryptoStore: reduceCryptoStore(state.cryptoStore, action), communityPickerStore, }; return validateState(oldState, state); } function validateState(oldState: AppState, state: AppState): AppState { 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 state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; } diff --git a/web/selectors/socket-selectors.js b/web/selectors/socket-selectors.js index d0ca97c35..7cff33b20 100644 --- a/web/selectors/socket-selectors.js +++ b/web/selectors/socket-selectors.js @@ -1,128 +1,129 @@ // @flow import olm from '@commapp/olm'; import { createSelector } from 'reselect'; +import { sessionIDSelector } from 'lib/selectors/keyserver-selectors.js'; import { getClientResponsesSelector, sessionStateFuncSelector, } from 'lib/selectors/socket-selectors.js'; import { createOpenSocketFunction } from 'lib/shared/socket-utils.js'; import type { OLMIdentityKeys, PickledOLMAccount, SignedIdentityKeysBlob, IdentityKeysBlob, } from 'lib/types/crypto-types.js'; import type { ClientServerRequest, ClientClientResponse, } from 'lib/types/request-types.js'; import type { SessionIdentification, SessionState, } from 'lib/types/session-types.js'; import type { OneTimeKeyGenerator } from 'lib/types/socket-types.js'; import { initOlm } from '../olm/olm-utils.js'; import type { AppState } from '../redux/redux-setup.js'; const openSocketSelector: (state: AppState) => () => WebSocket = createSelector( (state: AppState) => state.baseHref, createOpenSocketFunction, ); const sessionIdentificationSelector: ( state: AppState, ) => SessionIdentification = createSelector( - (state: AppState) => state.sessionID, + sessionIDSelector, (sessionID: ?string): SessionIdentification => ({ sessionID }), ); const getSignedIdentityKeysBlobSelector: ( state: AppState, ) => ?() => Promise = createSelector( (state: AppState) => state.cryptoStore.primaryAccount, (state: AppState) => state.cryptoStore.primaryIdentityKeys, (state: AppState) => state.cryptoStore.notificationIdentityKeys, ( primaryAccount: ?PickledOLMAccount, primaryIdentityKeys: ?OLMIdentityKeys, notificationIdentityKeys: ?OLMIdentityKeys, ) => { if (!primaryAccount || !primaryIdentityKeys || !notificationIdentityKeys) { return null; } return async () => { await 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; }; }, ); const webGetClientResponsesSelector: ( state: AppState, ) => ( serverRequests: $ReadOnlyArray, ) => Promise<$ReadOnlyArray> = createSelector( getClientResponsesSelector, getSignedIdentityKeysBlobSelector, (state: AppState) => state.navInfo.tab === 'calendar', ( getClientResponsesFunc: ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, getSignedIdentityKeysBlob: ?() => Promise, getInitialNotificationsEncryptedMessage: ?() => Promise, serverRequests: $ReadOnlyArray, ) => Promise<$ReadOnlyArray>, getSignedIdentityKeysBlob: ?() => Promise, calendarActive: boolean, ) => (serverRequests: $ReadOnlyArray) => getClientResponsesFunc( calendarActive, null, getSignedIdentityKeysBlob, null, serverRequests, ), ); const webSessionStateFuncSelector: (state: AppState) => () => SessionState = createSelector( sessionStateFuncSelector, (state: AppState) => state.navInfo.tab === 'calendar', ( sessionStateFunc: (calendarActive: boolean) => SessionState, calendarActive: boolean, ) => () => sessionStateFunc(calendarActive), ); export { openSocketSelector, sessionIdentificationSelector, getSignedIdentityKeysBlobSelector, webGetClientResponsesSelector, webSessionStateFuncSelector, };