diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index 4e540a259..43505ed22 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,261 +1,249 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { updatesCurrentAsOfSelector, currentAsOfSelector, urlPrefixSelector, cookieSelector, } from './keyserver-selectors.js'; import { currentCalendarQuery } from './nav-selectors.js'; import { createOpenSocketFunction } from '../shared/socket-utils.js'; import type { BoundStateSyncSpec } from '../shared/state-sync/state-sync-spec.js'; import { stateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { SignedIdentityKeysBlob } from '../types/crypto-types.js'; import { type CalendarQuery } from '../types/entry-types.js'; import type { AppState } from '../types/redux-types.js'; import type { ClientReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, type ClientServerRequest, type ClientClientResponse, } from '../types/request-types.js'; import type { SessionState } from '../types/session-types.js'; import { getConfig } from '../utils/config.js'; import { values } from '../utils/objects.js'; const baseOpenSocketSelector: ( keyserverID: string, ) => (state: AppState) => ?() => WebSocket = keyserverID => createSelector( urlPrefixSelector(keyserverID), // We don't actually use the cookie in the socket open function, // but we do use it in the initial message, and when the cookie changes // the socket needs to be reopened. By including the cookie here, // whenever the cookie changes this function will change, // which tells the Socket component to restart the connection. cookieSelector(keyserverID), (urlPrefix: ?string) => { if (!urlPrefix) { return null; } return createOpenSocketFunction(urlPrefix); }, ); const openSocketSelector: ( keyserverID: string, ) => (state: AppState) => ?() => WebSocket = _memoize(baseOpenSocketSelector); const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.reportStore.queuedReports, ( mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => mainQueuedReports, ); // We pass all selectors specified in stateSyncSpecs and get the resulting // BoundStateSyncSpecs in the specs array. We do it so we don't have to // modify the selector when we add a new spec. type BoundStateSyncSpecs = { +specsPerHashKey: { +[string]: BoundStateSyncSpec }, +specPerInnerHashKey: { +[string]: BoundStateSyncSpec }, }; const stateSyncSpecSelectors = values(stateSyncSpecs).map( spec => spec.selector, ); const boundStateSyncSpecsSelector: AppState => BoundStateSyncSpecs = // The FlowFixMe is needed because createSelector types require flow // to know the number of subselectors at compile time. // $FlowFixMe createSelector(stateSyncSpecSelectors, (...specs) => { const boundSpecs = (specs: BoundStateSyncSpec[]); // We create a map from `hashKey` to a given spec for easier lookup later const specsPerHashKey = Object.fromEntries( boundSpecs.map(spec => [spec.hashKey, spec]), ); // We do the same for innerHashKey const specPerInnerHashKey = Object.fromEntries( boundSpecs .filter(spec => spec.innerHashSpec?.hashKey) .map(spec => [spec.innerHashSpec?.hashKey, spec]), ); return { specsPerHashKey, specPerInnerHashKey }; }); async function getSignedIdentityKeysBlob(): Promise { const { olmAPI } = getConfig(); await olmAPI.initializeCryptoAccount(); const { blobPayload, signature } = await olmAPI.getUserPublicKey(); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: blobPayload, signature, }; return signedIdentityKeysBlob; } const getClientResponsesSelector: ( state: AppState, keyserverID: string, ) => ( calendarActive: boolean, getInitialNotificationsEncryptedMessage: () => Promise, serverRequests: $ReadOnlyArray, ) => Promise<$ReadOnlyArray> = createSelector( boundStateSyncSpecsSelector, currentCalendarQuery, (state: AppState, keyserverID: string) => keyserverID, ( boundStateSyncSpecs: BoundStateSyncSpecs, calendarQuery: (calendarActive: boolean) => CalendarQuery, keyserverID: string, ) => { return async ( calendarActive: boolean, getInitialNotificationsEncryptedMessage: () => Promise, serverRequests: $ReadOnlyArray, ): Promise<$ReadOnlyArray> => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( request => request.type === serverRequestTypes.PLATFORM_DETAILS, ); const { specsPerHashKey, specPerInnerHashKey } = boundStateSyncSpecs; for (const serverRequest of serverRequests) { if ( serverRequest.type === serverRequestTypes.PLATFORM && !serverRequestedPlatformDetails ) { clientResponses.push({ type: serverRequestTypes.PLATFORM, platform: getConfig().platformDetails.platform, }); } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { const query = calendarQuery(calendarActive); const hashResults: { [string]: boolean } = {}; for (const key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; - let hashValue; const [specKey, id] = key.split('|'); - if (id) { - hashValue = specPerInnerHashKey[specKey]?.getInfoHash( - id, - keyserverID, - ); - } else { - hashValue = specsPerHashKey[specKey]?.getAllInfosHash( - query, - keyserverID, - ); - } + const spec = id + ? specPerInnerHashKey[specKey] + : specsPerHashKey[specKey]; - // If hashValue values is null then we are still calculating - // the hashes in the background. In this case we return true - // to skip this state check. Future state checks (after the hash - // calculation complete) will be handled normally. - // Another case when this is null is when we are handling state - // sync with a non-authoritative keyserver. We don't want to sync - // user store and current user info with such keyservers. - if (!hashValue) { + if (!spec?.canSyncState(keyserverID)) { hashResults[key] = true; - } else { - hashResults[key] = expectedHashValue === hashValue; + continue; } + + const hashValue = id + ? spec?.getInfoHash(id, keyserverID) + : spec?.getAllInfosHash(query, keyserverID); + hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; for (const spec of values(specPerInnerHashKey)) { const innerHashKey = spec.innerHashSpec?.hashKey; if (!failUnmentioned?.[spec.hashKey] || !innerHashKey) { continue; } const ids = spec.getIDs(query, keyserverID); if (!ids) { continue; } for (const id of ids) { const key = `${innerHashKey}|${id}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } else if ( serverRequest.type === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB ) { const signedIdentityKeysBlob = await getSignedIdentityKeysBlob(); clientResponses.push({ type: serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, signedIdentityKeysBlob, }); } else if ( serverRequest.type === serverRequestTypes.INITIAL_NOTIFICATIONS_ENCRYPTED_MESSAGE ) { const initialNotificationsEncryptedMessage = await getInitialNotificationsEncryptedMessage(); clientResponses.push({ type: serverRequestTypes.INITIAL_NOTIFICATIONS_ENCRYPTED_MESSAGE, initialNotificationsEncryptedMessage, }); } } return clientResponses; }; }, ); const baseSessionStateFuncSelector: ( keyserverID: string, ) => ( state: AppState, ) => (calendarActive: boolean) => SessionState = keyserverID => createSelector( currentAsOfSelector(keyserverID), updatesCurrentAsOfSelector(keyserverID), currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); const sessionStateFuncSelector: ( keyserverID: string, ) => (state: AppState) => (calendarActive: boolean) => SessionState = _memoize( baseSessionStateFuncSelector, ); export { openSocketSelector, queuedReports, getClientResponsesSelector, sessionStateFuncSelector, }; diff --git a/lib/shared/state-sync/current-user-state-sync-spec.js b/lib/shared/state-sync/current-user-state-sync-spec.js index ba3a8d09f..0bbddaa8c 100644 --- a/lib/shared/state-sync/current-user-state-sync-spec.js +++ b/lib/shared/state-sync/current-user-state-sync-spec.js @@ -1,40 +1,42 @@ // @flow import { createSelector } from 'reselect'; import type { StateSyncSpec, BoundStateSyncSpec } from './state-sync-spec.js'; import type { CalendarQuery } from '../../types/entry-types.js'; import type { AppState } from '../../types/redux-types'; import { type CurrentUserInfo } from '../../types/user-types.js'; import { authoritativeKeyserverID } from '../../utils/authoritative-keyserver.js'; import { hash } from '../../utils/objects.js'; const selector: ( state: AppState, ) => BoundStateSyncSpec = createSelector( (state: AppState) => state.currentUserInfo, (currentUserInfo: ?CurrentUserInfo) => ({ ...currentUserStateSyncSpec, getInfoHash: () => hash(currentUserInfo), getAllInfosHash: (query: CalendarQuery, keyserverID: string) => { if (keyserverID !== authoritativeKeyserverID()) { return null; } return hash(currentUserInfo); }, getIDs: () => ([]: string[]), + canSyncState: (keyserverID: string) => + keyserverID === authoritativeKeyserverID(), }), ); export const currentUserStateSyncSpec: StateSyncSpec< CurrentUserInfo, CurrentUserInfo, void, > = Object.freeze({ hashKey: 'currentUserInfo', findStoreInconsistencies() { return undefined; }, selector, }); diff --git a/lib/shared/state-sync/entries-state-sync-spec.js b/lib/shared/state-sync/entries-state-sync-spec.js index 3de46f394..46c8ba8ff 100644 --- a/lib/shared/state-sync/entries-state-sync-spec.js +++ b/lib/shared/state-sync/entries-state-sync-spec.js @@ -1,137 +1,138 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { createSelector } from 'reselect'; import type { StateSyncSpec, BoundStateSyncSpec } from './state-sync-spec.js'; import { extractKeyserverIDFromID } from '../../keyserver-conn/keyserver-call-utils.js'; import { type CalendarQuery, type RawEntryInfos, type RawEntryInfo, } from '../../types/entry-types.js'; import type { AppState } from '../../types/redux-types.js'; import { reportTypes, type ClientEntryInconsistencyReportCreationRequest, } from '../../types/report-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; import { actionLogger } from '../../utils/action-logger.js'; import { getConfig } from '../../utils/config.js'; import { values, combineUnorderedHashes, hash } from '../../utils/objects.js'; import { generateReportID } from '../../utils/report-utils.js'; import { sanitizeActionSecrets } from '../../utils/sanitization.js'; import { filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from '../entry-utils.js'; const selector: ( state: AppState, ) => BoundStateSyncSpec< RawEntryInfos, RawEntryInfo, $ReadOnlyArray, > = createSelector( (state: AppState) => state.entryStore.entryInfos, (entryInfos: RawEntryInfos) => ({ ...entriesStateSyncSpec, getInfoHash: (id: string, keyserverID: string) => hash(entryInfos[`${keyserverID}|${id}`]), getAllInfosHash: (calendarQuery: CalendarQuery, keyserverID: string) => getEntryInfosHash(entryInfos, calendarQuery, keyserverID), getIDs: (calendarQuery: CalendarQuery, keyserverID: string) => getEntryIDs(entryInfos, calendarQuery, keyserverID), + canSyncState: () => true, }), ); export const entriesStateSyncSpec: StateSyncSpec< RawEntryInfos, RawEntryInfo, $ReadOnlyArray, > = Object.freeze({ hashKey: 'entryInfos', innerHashSpec: { hashKey: 'entryInfo', deleteKey: 'deleteEntryIDs', rawInfosKey: 'rawEntryInfos', }, findStoreInconsistencies( action: ProcessServerRequestAction, beforeStateCheck: RawEntryInfos, afterStateCheck: RawEntryInfos, ) { const keyserverID = action.payload.keyserverID; const filteredBeforeStateCheck = values(beforeStateCheck).filter( entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, ); const filteredAfterStateCheck = values(afterStateCheck).filter( entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, ); const calendarQuery = action.payload.calendarQuery; // We don't want to bother reporting an inconsistency if it's just because // of extraneous EntryInfos (not within the current calendarQuery) on either // side const filteredBeforeResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredBeforeStateCheck), calendarQuery, ); const filteredAfterResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredAfterStateCheck), calendarQuery, ); if (_isEqual(filteredBeforeResult)(filteredAfterResult)) { return emptyArray; } return [ { type: reportTypes.ENTRY_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), calendarQuery, pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; }, selector, }); const emptyArray: $ReadOnlyArray = []; function getEntryInfosHash( entryInfos: RawEntryInfos, calendarQuery: CalendarQuery, keyserverID: string, ) { const filteredEntries = values(entryInfos).filter( entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, ); const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredEntries), calendarQuery, ); return combineUnorderedHashes(Object.values(filteredEntryInfos).map(hash)); } function getEntryIDs( entryInfos: RawEntryInfos, calendarQuery: CalendarQuery, keyserverID: string, ) { const filteredEntries = values(entryInfos).filter( entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, ); const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredEntries), calendarQuery, ); return Object.keys(filteredEntryInfos).map(id => id.split('|')[1]); } diff --git a/lib/shared/state-sync/state-sync-spec.js b/lib/shared/state-sync/state-sync-spec.js index 91aa47300..ec4a5d3ea 100644 --- a/lib/shared/state-sync/state-sync-spec.js +++ b/lib/shared/state-sync/state-sync-spec.js @@ -1,35 +1,44 @@ // @flow import type { CalendarQuery } from '../../types/entry-types.js'; import type { AppState } from '../../types/redux-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; export type StateSyncSpec = { +hashKey: string, +innerHashSpec?: { +hashKey: string, +deleteKey: string, +rawInfosKey: string, +additionalDeleteCondition?: Info => boolean, }, +findStoreInconsistencies: ( action: ProcessServerRequestAction, beforeStateCheck: Infos, afterStateCheck: Infos, ) => Inconsistencies, +selector: ( state: AppState, ) => BoundStateSyncSpec, }; // All ids specified here (getInfoHash and getIDs) are server ids. // E.g. in the case of threadStore or entryStore the keyserver prefix -// needs to be handled additionaly +// needs to be handled additionally export type BoundStateSyncSpec = { // If these function depend on background hashing that is still not complete // they should return null, to indicate that the hashes aren't available yet +getInfoHash: (id: string, keyserverID: string) => ?number, +getAllInfosHash: (query: CalendarQuery, keyserverID: string) => ?number, +getIDs: (query: CalendarQuery, keyserverID: string) => ?Array, + // This function is used to check if we can sync the state. + // Currently, there are two cases where we can't sync the state: + // 1. When background thread hashing is still in progress + // 2. When a non-authoritative keyserver attempts a state sync of UserStore + // or CurrentUserInfo + // If we can't do a state sync, we're informing a keyserver that all + // the hashes are correct. Later, the keyserver will attempt a state sync + // again. + +canSyncState: (keyserverID: string) => boolean, ...StateSyncSpec, }; diff --git a/lib/shared/state-sync/threads-state-sync-spec.js b/lib/shared/state-sync/threads-state-sync-spec.js index 80e7d0005..a96a4941e 100644 --- a/lib/shared/state-sync/threads-state-sync-spec.js +++ b/lib/shared/state-sync/threads-state-sync-spec.js @@ -1,99 +1,100 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import _pickBy from 'lodash/fp/pickBy.js'; import { createSelector } from 'reselect'; import type { StateSyncSpec, BoundStateSyncSpec } from './state-sync-spec.js'; import { extractKeyserverIDFromID } from '../../keyserver-conn/keyserver-call-utils.js'; import type { CalendarQuery } from '../../types/entry-types.js'; import type { RawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { AppState } from '../../types/redux-types.js'; import { reportTypes, type ClientThreadInconsistencyReportCreationRequest, } from '../../types/report-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; import { type MixedRawThreadInfos, type LegacyRawThreadInfo, } from '../../types/thread-types.js'; import { actionLogger } from '../../utils/action-logger.js'; import { getConfig } from '../../utils/config.js'; import { combineUnorderedHashes, entries } from '../../utils/objects.js'; import { generateReportID } from '../../utils/report-utils.js'; import { sanitizeActionSecrets } from '../../utils/sanitization.js'; const selector: ( state: AppState, ) => BoundStateSyncSpec< MixedRawThreadInfos, LegacyRawThreadInfo | RawThreadInfo, $ReadOnlyArray, > = createSelector( (state: AppState) => state.integrityStore.threadHashes, (state: AppState) => state.integrityStore.threadHashingStatus === 'completed', (threadHashes: { +[string]: number }, threadHashingComplete: boolean) => ({ ...threadsStateSyncSpec, getInfoHash: (id: string, keyserverID: string) => threadHashes[`${keyserverID}|${id}`], getAllInfosHash: threadHashingComplete ? (query: CalendarQuery, keyserverID: string) => combineUnorderedHashes( entries(threadHashes) .filter(([id]) => extractKeyserverIDFromID(id) === keyserverID) .map(([, threadHash]) => threadHash), ) : () => null, getIDs: threadHashingComplete ? (query: CalendarQuery, keyserverID: string) => Object.keys(threadHashes) .filter(id => extractKeyserverIDFromID(id) === keyserverID) .map(id => id.split('|')[1]) : () => null, + canSyncState: () => threadHashingComplete, }), ); export const threadsStateSyncSpec: StateSyncSpec< MixedRawThreadInfos, LegacyRawThreadInfo | RawThreadInfo, $ReadOnlyArray, > = Object.freeze({ hashKey: 'threadInfos', innerHashSpec: { hashKey: 'threadInfo', deleteKey: 'deleteThreadIDs', rawInfosKey: 'rawThreadInfos', }, findStoreInconsistencies( action: ProcessServerRequestAction, beforeStateCheck: MixedRawThreadInfos, afterStateCheck: MixedRawThreadInfos, ) { const keyserverID = action.payload.keyserverID; const filter = _pickBy( thread => extractKeyserverIDFromID(thread.id) === keyserverID, ); const filteredBeforeStateCheck = filter(beforeStateCheck); const filteredAfterStateCheck = filter(afterStateCheck); if (_isEqual(filteredBeforeStateCheck)(filteredAfterStateCheck)) { return emptyArray; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; }, selector, }); const emptyArray: $ReadOnlyArray = []; diff --git a/lib/shared/state-sync/users-state-sync-spec.js b/lib/shared/state-sync/users-state-sync-spec.js index 85581e8ef..8c2b71f68 100644 --- a/lib/shared/state-sync/users-state-sync-spec.js +++ b/lib/shared/state-sync/users-state-sync-spec.js @@ -1,87 +1,89 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { createSelector } from 'reselect'; import type { StateSyncSpec, BoundStateSyncSpec } from './state-sync-spec.js'; import type { CalendarQuery } from '../../types/entry-types.js'; import type { AppState } from '../../types/redux-types'; import { type ClientUserInconsistencyReportCreationRequest, reportTypes, } from '../../types/report-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; import { type UserInfo, type UserInfos } from '../../types/user-types.js'; import { actionLogger } from '../../utils/action-logger.js'; import { authoritativeKeyserverID } from '../../utils/authoritative-keyserver.js'; import { getConfig } from '../../utils/config.js'; import { combineUnorderedHashes, hash } from '../../utils/objects.js'; import { generateReportID } from '../../utils/report-utils.js'; import { sanitizeActionSecrets } from '../../utils/sanitization.js'; const selector: ( state: AppState, ) => BoundStateSyncSpec< UserInfos, UserInfo, $ReadOnlyArray, > = createSelector( (state: AppState) => state.userStore.userInfos, (userInfos: UserInfos) => ({ ...usersStateSyncSpec, getInfoHash: (id: string) => hash(userInfos[id]), getAllInfosHash: (query: CalendarQuery, keyserverID: string) => { if (keyserverID !== authoritativeKeyserverID()) { return null; } return combineUnorderedHashes(Object.values(userInfos).map(hash)); }, getIDs: (query: CalendarQuery, keyserverID: string) => { if (keyserverID !== authoritativeKeyserverID()) { return null; } return Object.keys(userInfos); }, + canSyncState: (keyserverID: string) => + keyserverID === authoritativeKeyserverID(), }), ); export const usersStateSyncSpec: StateSyncSpec< UserInfos, UserInfo, $ReadOnlyArray, > = Object.freeze({ hashKey: 'userInfos', innerHashSpec: { hashKey: 'userInfo', deleteKey: 'deleteUserInfoIDs', rawInfosKey: 'userInfos', additionalDeleteCondition(user: UserInfo) { return !user.username; }, }, findStoreInconsistencies( action: ProcessServerRequestAction, beforeStateCheck: UserInfos, afterStateCheck: UserInfos, ) { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return emptyArray; } return [ { type: reportTypes.USER_INCONSISTENCY, platformDetails: getConfig().platformDetails, action: sanitizeActionSecrets(action), beforeStateCheck, afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; }, selector, }); const emptyArray: $ReadOnlyArray = [];