diff --git a/lib/reducers/integrity-reducer.js b/lib/reducers/integrity-reducer.js index 4dc3f106d..488418b55 100644 --- a/lib/reducers/integrity-reducer.js +++ b/lib/reducers/integrity-reducer.js @@ -1,157 +1,196 @@ // @flow import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { updateIntegrityStoreActionType } from '../actions/integrity-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { keyserverAuthActionTypes, logInActionTypes, keyserverRegisterActionTypes, } from '../actions/user-actions.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { integrityStoreOpsHandlers, type IntegrityStoreOperation, } from '../ops/integrity-store-ops.js'; import type { ThreadStoreOperation } from '../ops/thread-store-ops'; import type { IntegrityStore, ThreadHashes } from '../types/integrity-types'; import type { RawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType } from '../types/socket-types.js'; -import { hash } from '../utils/objects.js'; +import { getMessageForException } from '../utils/errors.js'; +import { assertObjectsAreEqual, hash } from '../utils/objects.js'; const { processStoreOperations: processStoreOps } = integrityStoreOpsHandlers; +function assertIntegrityStoresAreEqual( + processedIntegrityStore: ThreadHashes, + expectedIntegrityStore: ThreadHashes, + location: string, + onStateDifference?: (message: string) => mixed, +) { + try { + assertObjectsAreEqual( + processedIntegrityStore, + expectedIntegrityStore, + `IntegrityInfos - ${location}`, + ); + } catch (e) { + console.log( + 'Error processing IntegrityStore ops', + processedIntegrityStore, + expectedIntegrityStore, + ); + const message = `Error processing IntegrityStore ops ${ + getMessageForException(e) ?? '{no exception message}' + }`; + onStateDifference?.(message); + } +} + function reduceIntegrityStore( state: IntegrityStore, action: BaseAction, + onStateDifference?: (message: string) => mixed, threadInfos: { +[string]: RawThreadInfo, }, threadStoreOperations: $ReadOnlyArray, ): { +integrityStore: IntegrityStore, +integrityStoreOperations: $ReadOnlyArray, } { if (action.type === fullStateSyncActionType) { const removeAllOperation = { type: 'remove_all_integrity_thread_hashes' }; const threadHashesArray = Object.entries(state.threadHashes).filter( ([key]) => extractKeyserverIDFromID(key) !== action.payload.keyserverID, ); const replaceOperation = { type: 'replace_integrity_thread_hashes', payload: { threadHashes: Object.fromEntries(threadHashesArray) }, }; return { integrityStore: { threadHashes: processStoreOps(state, [ removeAllOperation, replaceOperation, ]).threadHashes, threadHashingStatus: 'starting', }, integrityStoreOperations: [removeAllOperation, replaceOperation], }; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === keyserverRegisterActionTypes.success || (action.type === setClientDBStoreActionType && !!action.payload.threadStore && state.threadHashingStatus !== 'completed') ) { const removeAllOperation = { type: 'remove_all_integrity_thread_hashes' }; return { integrityStore: { threadHashes: processStoreOps(state, [removeAllOperation]).threadHashes, threadHashingStatus: 'starting', }, integrityStoreOperations: [removeAllOperation], }; } else if (action.type === keyserverAuthActionTypes.success) { return { integrityStore: { threadHashes: processStoreOps(state, []).threadHashes, threadHashingStatus: 'starting', }, integrityStoreOperations: [], }; + } else if (action.type === setClientDBStoreActionType) { + assertIntegrityStoresAreEqual( + action.payload.threadHashes ?? {}, + state.threadHashes, + action.type, + onStateDifference, + ); + return { + integrityStore: state, + integrityStoreOperations: [], + }; } + let newState = state; const integrityOperations: IntegrityStoreOperation[] = []; if (action.type === updateIntegrityStoreActionType) { if (action.payload.threadIDsToHash) { const newThreadHashes = Object.fromEntries( action.payload.threadIDsToHash .map(id => [id, threadInfos[id]]) .filter(([, info]) => !!info) .map(([id, info]) => [id, hash(info)]), ); const replaceOperation = { type: 'replace_integrity_thread_hashes', payload: { threadHashes: newThreadHashes }, }; newState = processStoreOps(state, [replaceOperation]); integrityOperations.push(replaceOperation); } if (action.payload.threadHashingStatus) { newState = { ...newState, threadHashingStatus: action.payload.threadHashingStatus, }; } } if (threadStoreOperations.length === 0) { return { integrityStore: newState, integrityStoreOperations: integrityOperations, }; } let groupedReplaceThreadHashes: ThreadHashes = {}; let threadHashingStatus = newState.threadHashingStatus; for (const operation of threadStoreOperations) { if ( operation.type !== 'replace' && Object.keys(groupedReplaceThreadHashes).length > 0 ) { integrityOperations.push({ type: 'replace_integrity_thread_hashes', payload: { threadHashes: groupedReplaceThreadHashes }, }); groupedReplaceThreadHashes = {}; } if (operation.type === 'replace') { const newIntegrityThreadHash = hash(operation.payload.threadInfo); groupedReplaceThreadHashes = { ...groupedReplaceThreadHashes, [operation.payload.id]: newIntegrityThreadHash, }; } else if (operation.type === 'remove') { integrityOperations.push({ type: 'remove_integrity_thread_hashes', payload: { ids: operation.payload.ids }, }); } else if (operation.type === 'remove_all') { integrityOperations.push({ type: 'remove_all_integrity_thread_hashes' }); threadHashingStatus = 'completed'; } } if (Object.keys(groupedReplaceThreadHashes).length > 0) { integrityOperations.push({ type: 'replace_integrity_thread_hashes', payload: { threadHashes: groupedReplaceThreadHashes }, }); } return { integrityStore: { threadHashes: processStoreOps(newState, integrityOperations).threadHashes, threadHashingStatus, }, integrityStoreOperations: integrityOperations, }; } export { reduceIntegrityStore }; diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index 709d2cf45..5daefeeb9 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,223 +1,224 @@ // @flow import reduceCalendarFilters from './calendar-filters-reducer.js'; import { reduceCommunityStore } from './community-reducer.js'; import reduceCustomerServer from './custom-server-reducer.js'; import reduceDataLoaded from './data-loaded-reducer.js'; import { reduceDraftStore } from './draft-reducer.js'; import reduceEnabledApps from './enabled-apps-reducer.js'; import { reduceEntryInfos } from './entry-reducer.js'; import { reduceIntegrityStore } from './integrity-reducer.js'; import reduceInviteLinks from './invite-links-reducer.js'; import reduceKeyserverStore from './keyserver-reducer.js'; import reduceLifecycleState from './lifecycle-state-reducer.js'; import { reduceLoadingStatuses } from './loading-reducer.js'; import reduceNextLocalID from './local-id-reducer.js'; import { reduceMessageStore } from './message-reducer.js'; import reduceBaseNavInfo from './nav-reducer.js'; import { reduceNotifPermissionAlertInfo } from './notif-permission-alert-info-reducer.js'; import policiesReducer from './policies-reducer.js'; import reduceReportStore from './report-store-reducer.js'; import reduceServicesAccessToken from './services-access-token-reducer.js'; import reduceGlobalThemeInfo from './theme-reducer.js'; import { reduceThreadActivity } from './thread-activity-reducer.js'; import { reduceThreadInfos } from './thread-reducer.js'; import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer.js'; import { addKeyserverActionType } from '../actions/keyserver-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { keyserverRegisterActionTypes, logInActionTypes, } from '../actions/user-actions.js'; import { keyserverStoreOpsHandlers, type ReplaceKeyserverOperation, } from '../ops/keyserver-store-ops.js'; import { isStaff } from '../shared/staff-utils.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState, BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { StoreOperations } from '../types/store-ops-types.js'; import { isDev } from '../utils/dev-utils.js'; export default function baseReducer>( state: T, action: BaseAction, onStateDifference: (message: string) => mixed, ): { state: T, storeOperations: StoreOperations } { const { threadStore, newThreadInconsistencies, threadStoreOperations } = reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; const [entryStore, newEntryInconsistencies] = reduceEntryInfos( state.entryStore, action, threadInfos, ); const onStateDifferenceForStaff = (message: string) => { const isCurrentUserStaff = state.currentUserInfo?.id ? isStaff(state.currentUserInfo.id) : false; if (isCurrentUserStaff || isDev) { onStateDifference(message); } }; const [userStore, newUserInconsistencies, userStoreOperations] = reduceUserInfos(state.userStore, action, onStateDifferenceForStaff); const newInconsistencies = [ ...newEntryInconsistencies, ...newThreadInconsistencies, ...newUserInconsistencies, ]; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC const { messageStoreOperations, messageStore: reducedMessageStore } = reduceMessageStore(state.messageStore, action, threadInfos); let messageStore = reducedMessageStore; let { keyserverStore, keyserverStoreOperations } = reduceKeyserverStore( state.keyserverStore, action, onStateDifferenceForStaff, ); if ( action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType && action.type !== keyserverRegisterActionTypes.success && action.type !== logInActionTypes.success && action.type !== siweAuthActionTypes.success && action.type !== addKeyserverActionType ) { const replaceOperations: ReplaceKeyserverOperation[] = []; for (const keyserverID in keyserverStore.keyserverInfos) { if ( keyserverStore.keyserverInfos[keyserverID].connection.status === 'connected' ) { continue; } if ( messageStore.currentAsOf[keyserverID] !== state.messageStore.currentAsOf[keyserverID] ) { messageStore = { ...messageStore, currentAsOf: { ...messageStore.currentAsOf, [keyserverID]: state.messageStore.currentAsOf[keyserverID], }, }; } if ( state.keyserverStore.keyserverInfos[keyserverID] && keyserverStore.keyserverInfos[keyserverID].updatesCurrentAsOf !== state.keyserverStore.keyserverInfos[keyserverID].updatesCurrentAsOf ) { replaceOperations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...keyserverStore.keyserverInfos[keyserverID], updatesCurrentAsOf: state.keyserverStore.keyserverInfos[keyserverID] .updatesCurrentAsOf, }, }, }); } } keyserverStore = keyserverStoreOpsHandlers.processStoreOperations( keyserverStore, replaceOperations, ); keyserverStoreOperations = [ ...keyserverStoreOperations, ...replaceOperations, ]; } const { draftStore, draftStoreOperations } = reduceDraftStore( state.draftStore, action, ); const { reportStore, reportStoreOperations } = reduceReportStore( state.reportStore, action, newInconsistencies, ); const { communityStore, communityStoreOperations } = reduceCommunityStore( state.communityStore, action, ); const { integrityStore, integrityStoreOperations } = reduceIntegrityStore( state.integrityStore, action, + onStateDifferenceForStaff, threadInfos, threadStoreOperations, ); return { state: { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), draftStore, entryStore, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, userStore, messageStore, calendarFilters: reduceCalendarFilters( state.calendarFilters, action, threadStore, ), notifPermissionAlertInfo: reduceNotifPermissionAlertInfo( state.notifPermissionAlertInfo, action, ), lifecycleState: reduceLifecycleState(state.lifecycleState, action), enabledApps: reduceEnabledApps(state.enabledApps, action), reportStore, nextLocalID: reduceNextLocalID(state.nextLocalID, action), dataLoaded: reduceDataLoaded(state.dataLoaded, action), userPolicies: policiesReducer(state.userPolicies, action), commServicesAccessToken: reduceServicesAccessToken( state.commServicesAccessToken, action, ), inviteLinksStore: reduceInviteLinks(state.inviteLinksStore, action), keyserverStore, threadActivityStore: reduceThreadActivity( state.threadActivityStore, action, ), integrityStore, globalThemeInfo: reduceGlobalThemeInfo(state.globalThemeInfo, action), customServer: reduceCustomerServer(state.customServer, action), communityStore, }, storeOperations: { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, userStoreOperations, keyserverStoreOperations, communityStoreOperations, integrityStoreOperations, }, }; } diff --git a/lib/reducers/message-reducer.test.js b/lib/reducers/message-reducer.test.js index ef5abee42..831b266e9 100644 --- a/lib/reducers/message-reducer.test.js +++ b/lib/reducers/message-reducer.test.js @@ -1,319 +1,320 @@ // @flow import invariant from 'invariant'; import { reduceMessageStore } from './message-reducer.js'; import { createPendingThread } from '../shared/thread-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import type { MessageStore } from '../types/message-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; const messageStoreBeforeMediaUpdate: MessageStore = { messages: { local1: { type: 14, threadID: '91140', creatorID: '91097', time: 1639522317443, media: [ { id: 'localUpload2', uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC', type: 'photo', dimensions: { height: 3024, width: 4032 }, thumbHash: 'some_thumb_hash', localMediaSelection: { step: 'photo_library', dimensions: { height: 3024, width: 4032 }, uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC', filename: 'IMG_0006.HEIC', mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001', selectTime: 1639522317349, sendTime: 1639522317349, retries: 0, }, }, ], localID: 'local1', }, }, threads: { '91140': { messageIDs: ['local1'], startReached: true, }, }, local: {}, currentAsOf: { [authoritativeKeyserverID()]: 1639522292174 }, }; describe('UPDATE_MULTIMEDIA_MESSAGE_MEDIA', () => { const updateMultiMediaMessageMediaAction = { type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: { messageID: 'local1', currentMediaID: 'localUpload2', mediaUpdate: { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, localMediaSelection: undefined, }, }, }; const { messageStore: updatedMessageStore } = reduceMessageStore( messageStoreBeforeMediaUpdate, updateMultiMediaMessageMediaAction, {}, ); test('replace local media with uploaded media', () => { expect( updatedMessageStore.messages[ updateMultiMediaMessageMediaAction.payload.messageID ], ).toStrictEqual({ type: 14, threadID: '91140', creatorID: '91097', time: 1639522317443, media: [ { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, thumbHash: 'some_thumb_hash', }, ], localID: 'local1', }); }); test('localMediaSelection is unset when undefined in update', () => { const msg = updatedMessageStore.messages[ updateMultiMediaMessageMediaAction.payload.messageID ]; expect(msg.type).toEqual(messageTypes.IMAGES); invariant(msg.type === messageTypes.IMAGES, 'message is of type IMAGES'); expect(msg.media[0]).not.toHaveProperty('localMediaSelection'); }); test('localMediaSelection is unchanged when missing in update', () => { const actionWithoutLocalMediaSelectionUpdate = { type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: { messageID: 'local1', currentMediaID: 'localUpload2', mediaUpdate: { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, }, }, }; const { messageStore: storeWithoutLocalMediaSelectionUpdate } = reduceMessageStore( messageStoreBeforeMediaUpdate, actionWithoutLocalMediaSelectionUpdate, {}, ); const prevMsg = messageStoreBeforeMediaUpdate.messages[ actionWithoutLocalMediaSelectionUpdate.payload.messageID ]; const updatedMsg = storeWithoutLocalMediaSelectionUpdate.messages[ actionWithoutLocalMediaSelectionUpdate.payload.messageID ]; expect(updatedMsg.type).toEqual(messageTypes.IMAGES); expect(prevMsg.type).toEqual(messageTypes.IMAGES); invariant( updatedMsg.type === messageTypes.IMAGES && prevMsg.type === messageTypes.IMAGES, 'message is of type IMAGES', ); expect(updatedMsg.media[0].localMediaSelection).toStrictEqual( prevMsg.media[0].localMediaSelection, ); }); test('localMediaSelection is updated when included in update', () => { const updateMultiMediaMessageMediaActionWithReplacement = { type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: { messageID: 'local1', currentMediaID: 'localUpload2', mediaUpdate: { id: '91172', type: 'photo', uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde', dimensions: { height: 1440, width: 1920 }, localMediaSelection: { step: 'photo_library', dimensions: { height: 10, width: 10 }, uri: 'assets-library://asset/new/path', filename: 'NEWNAME.PNG', mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001', selectTime: 1639522317349, sendTime: 1639522317349, retries: 1, }, }, }, }; const { messageStore: updatedMessageStoreWithReplacement } = reduceMessageStore( messageStoreBeforeMediaUpdate, updateMultiMediaMessageMediaActionWithReplacement, {}, ); const updatedMsg = updatedMessageStoreWithReplacement.messages[ updateMultiMediaMessageMediaActionWithReplacement.payload.messageID ]; expect(updatedMsg.type).toEqual(messageTypes.IMAGES); invariant( updatedMsg.type === messageTypes.IMAGES, 'message is of type IMAGES', ); expect(updatedMsg.media[0].localMediaSelection).toStrictEqual({ step: 'photo_library', dimensions: { height: 10, width: 10 }, uri: 'assets-library://asset/new/path', filename: 'NEWNAME.PNG', mediaNativeID: 'CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001', selectTime: 1639522317349, sendTime: 1639522317349, retries: 1, }); }); }); describe('SET_MESSAGE_STORE_MESSAGES', () => { const clientDBMessages = [ { id: '103502', local_id: null, thread: '88471', user: '83809', type: '14', future_type: null, content: '[103501]', time: '1658168455316', media_infos: [ { id: '103501', uri: 'http://localhost/comm/upload/103501/425db25471f3acd5', type: 'photo', extras: '{"dimensions":{"width":1920,"height":1440},"loop":false}', }, ], }, { id: 'local10', local_id: 'local10', thread: '88471', user: '83809', type: '14', future_type: null, content: '[null]', time: '1658172650495', media_infos: [ { id: 'localUpload0', uri: 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic', type: 'photo', extras: '{"dimensions":{"height":3024,"width":4032},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":3024,"width":4032},"uri":"assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic","filename":"IMG_0006.HEIC","mediaNativeID":"CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001","selectTime":1658172650370,"sendTime":1658172650370,"retries":0}}', }, ], }, { id: 'local11', local_id: 'local11', thread: '88471', user: '83809', type: '14', future_type: null, content: '[null,null]', time: '1658172656976', media_infos: [ { id: 'localUpload2', uri: 'assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic', type: 'photo', extras: '{"dimensions":{"height":3024,"width":4032},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":3024,"width":4032},"uri":"assets-library://asset/asset.heic?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=heic","filename":"IMG_0006.HEIC","mediaNativeID":"CC95F08C-88C3-4012-9D6D-64A413D254B3/L0/001","selectTime":1658172656826,"sendTime":1658172656826,"retries":0}}', }, { id: 'localUpload4', uri: 'assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg', type: 'photo', extras: '{"dimensions":{"height":2002,"width":3000},"loop":false,"local_media_selection":{"step":"photo_library","dimensions":{"height":2002,"width":3000},"uri":"assets-library://asset/asset.jpg?id=ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED&ext=jpg","filename":"IMG_0005.JPG","mediaNativeID":"ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED/L0/001","selectTime":1658172656826,"sendTime":1658172656826,"retries":0}}', }, ], }, ]; const clientDBThreads = [ { id: '88471', start_reached: '0', }, ]; const { messageStore: updatedMessageStore } = reduceMessageStore( { messages: {}, threads: {}, local: {}, currentAsOf: { [authoritativeKeyserverID()]: 1234567890123 }, }, { type: 'SET_CLIENT_DB_STORE', payload: { currentUserID: '', drafts: [], threadStore: { threadInfos: {}, }, messageStoreThreads: clientDBThreads, messages: clientDBMessages, reports: [], users: {}, keyserverInfos: {}, communityInfos: {}, + threadHashes: {}, }, }, { [88471]: createPendingThread({ viewerID: '', threadType: threadTypes.LOCAL, members: [{ id: '', username: '' }], }), }, ); test('removes local media when constructing messageStore.messages', () => { expect(updatedMessageStore.messages).toHaveProperty('103502'); expect(updatedMessageStore.messages).not.toHaveProperty('local10'); expect(updatedMessageStore.messages).not.toHaveProperty('local11'); }); test('removes local media when constructing messageStore.threads', () => { expect(updatedMessageStore).toBeDefined(); expect(updatedMessageStore.threads).toBeDefined(); expect(updatedMessageStore.threads['88471']).toBeDefined(); expect(updatedMessageStore.threads['88471'].messageIDs).toBeDefined(); expect(updatedMessageStore.threads['88471'].messageIDs).toEqual(['103502']); }); }); diff --git a/lib/types/store-ops-types.js b/lib/types/store-ops-types.js index e9d751c38..46e27de9b 100644 --- a/lib/types/store-ops-types.js +++ b/lib/types/store-ops-types.js @@ -1,93 +1,95 @@ // @flow import type { CommunityInfos } from './community-types.js'; import type { DraftStoreOperation, ClientDBDraftStoreOperation, ClientDBDraftInfo, } from './draft-types.js'; +import type { ThreadHashes } from './integrity-types.js'; import type { KeyserverInfos } from './keyserver-types.js'; import type { ClientDBMessageInfo, ClientDBThreadMessageInfo, } from './message-types.js'; import type { ClientReportCreationRequest } from './report-types.js'; import type { ClientDBThreadInfo, ThreadStore } from './thread-types.js'; import type { UserInfos } from './user-types.js'; import type { ClientDBCommunityInfo, ClientDBCommunityStoreOperation, CommunityStoreOperation, } from '../ops/community-store-ops.js'; import type { ClientDBIntegrityThreadHash, ClientDBIntegrityStoreOperation, IntegrityStoreOperation, } from '../ops/integrity-store-ops.js'; import type { ClientDBKeyserverInfo, ClientDBKeyserverStoreOperation, KeyserverStoreOperation, } from '../ops/keyserver-store-ops.js'; import type { ClientDBMessageStoreOperation, MessageStoreOperation, } from '../ops/message-store-ops.js'; import type { ReportStoreOperation, ClientDBReport, ClientDBReportStoreOperation, } from '../ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation, ThreadStoreOperation, } from '../ops/thread-store-ops.js'; import type { UserStoreOperation, ClientDBUserInfo, } from '../ops/user-store-ops.js'; export type StoreOperations = { +draftStoreOperations: $ReadOnlyArray, +threadStoreOperations: $ReadOnlyArray, +messageStoreOperations: $ReadOnlyArray, +reportStoreOperations: $ReadOnlyArray, +userStoreOperations: $ReadOnlyArray, +keyserverStoreOperations: $ReadOnlyArray, +communityStoreOperations: $ReadOnlyArray, +integrityStoreOperations: $ReadOnlyArray, }; export type ClientDBStoreOperations = { +draftStoreOperations?: $ReadOnlyArray, +threadStoreOperations?: $ReadOnlyArray, +messageStoreOperations?: $ReadOnlyArray, +reportStoreOperations?: $ReadOnlyArray, +keyserverStoreOperations?: $ReadOnlyArray, +communityStoreOperations?: $ReadOnlyArray, +integrityStoreOperations?: $ReadOnlyArray, }; export type ClientDBStore = { +messages: $ReadOnlyArray, +drafts: $ReadOnlyArray, +threads: $ReadOnlyArray, +messageStoreThreads: $ReadOnlyArray, +reports: $ReadOnlyArray, +users: $ReadOnlyArray, +keyservers: $ReadOnlyArray, +communities: $ReadOnlyArray, +integrityThreadHashes: $ReadOnlyArray, }; export type ClientStore = { +currentUserID: ?string, +drafts: $ReadOnlyArray, +messages: ?$ReadOnlyArray, +threadStore: ?ThreadStore, +messageStoreThreads: ?$ReadOnlyArray, +reports: ?$ReadOnlyArray, +users: ?UserInfos, +keyserverInfos: ?KeyserverInfos, +communityInfos: ?CommunityInfos, + +threadHashes: ?ThreadHashes, }; diff --git a/native/data/sqlite-data-handler.js b/native/data/sqlite-data-handler.js index 602c32022..a0a4ed4e2 100644 --- a/native/data/sqlite-data-handler.js +++ b/native/data/sqlite-data-handler.js @@ -1,265 +1,272 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import type { CallKeyserverEndpoint } from 'lib/keyserver-conn/keyserver-conn-types.js'; import { useKeyserverRecoveryLogIn } from 'lib/keyserver-conn/recovery-utils.js'; import { communityStoreOpsHandlers } from 'lib/ops/community-store-ops.js'; +import { integrityStoreOpsHandlers } from 'lib/ops/integrity-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 { userStoreOpsHandlers } from 'lib/ops/user-store-ops.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import { recoveryActionSources, type RecoveryActionSource, } from 'lib/types/account-types.js'; import type { CallSingleKeyserverEndpoint } from 'lib/utils/call-single-keyserver-endpoint.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { supportingMultipleKeyservers } from 'lib/utils/services-utils.js'; import { resolveKeyserverSessionInvalidationUsingNativeCredentials } from '../account/legacy-recover-keyserver-session.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { filesystemMediaCache } from '../media/media-cache.js'; import { commCoreModule } from '../native-modules.js'; import { setStoreLoadedActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import Alert from '../utils/alert.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; async function clearSensitiveData() { await commCoreModule.clearSensitiveData(); try { await filesystemMediaCache.clearCache(); } catch { throw new Error('clear_media_cache_failed'); } } const returnsFalseSinceDoesntNeedToSupportCancellation = () => false; function SQLiteDataHandler(): React.Node { const storeLoaded = useSelector(state => state.storeLoaded); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const staffCanSee = useStaffCanSee(); const loggedIn = useSelector(isLoggedIn); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); const mediaCacheContext = React.useContext(MediaCacheContext); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage(authoritativeKeyserverID); const keyserverRecoveryLogIn = useKeyserverRecoveryLogIn( authoritativeKeyserverID, ); const recoverDataFromAuthoritativeKeyserver = React.useCallback( async (source: RecoveryActionSource) => { const innerRecoverDataFromAuthoritativeKeyserver = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, ) => resolveKeyserverSessionInvalidationUsingNativeCredentials( callSingleKeyserverEndpoint, callKeyserverEndpoint, dispatchActionPromise, source, authoritativeKeyserverID, getInitialNotificationsEncryptedMessage, returnsFalseSinceDoesntNeedToSupportCancellation, ); try { await keyserverRecoveryLogIn( source, innerRecoverDataFromAuthoritativeKeyserver, returnsFalseSinceDoesntNeedToSupportCancellation, ); dispatch({ type: setStoreLoadedActionType }); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { commCoreModule.terminate(); } } }, [ dispatch, dispatchActionPromise, keyserverRecoveryLogIn, staffCanSee, getInitialNotificationsEncryptedMessage, ], ); const recoverData = React.useCallback( (source: RecoveryActionSource) => { if (supportingMultipleKeyservers) { invariant( false, 'recoverData in SQLiteDataHandler is not yet implemented when ' + 'supportingMultipleKeyservers is enabled. It should recover ' + 'from broken SQLite state by restoring from backup service', ); } return recoverDataFromAuthoritativeKeyserver(source); }, [recoverDataFromAuthoritativeKeyserver], ); const callClearSensitiveData = React.useCallback( async (triggeredBy: string) => { await clearSensitiveData(); console.log(`SQLite database deletion was triggered by ${triggeredBy}`); }, [], ); const handleSensitiveData = React.useCallback(async () => { try { const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID(); if ( databaseCurrentUserInfoID && databaseCurrentUserInfoID !== currentLoggedInUserID ) { await callClearSensitiveData('change in logged-in user credentials'); } if (currentLoggedInUserID) { await commCoreModule.setCurrentUserID(currentLoggedInUserID); } } catch (e) { if (isTaskCancelledError(e)) { return; } if (__DEV__) { throw e; } console.log(e); if (e.message !== 'clear_media_cache_failed') { commCoreModule.terminate(); } } }, [callClearSensitiveData, currentLoggedInUserID]); React.useEffect(() => { if (!rehydrateConcluded) { return; } const databaseNeedsDeletion = commCoreModule.checkIfDatabaseNeedsDeletion(); if (databaseNeedsDeletion) { void (async () => { try { await callClearSensitiveData('detecting corrupted database'); } catch (e) { if (__DEV__) { throw e; } console.log(e); if (e.message !== 'clear_media_cache_failed') { commCoreModule.terminate(); } } await recoverData(recoveryActionSources.corruptedDatabaseDeletion); })(); return; } const sensitiveDataHandled = handleSensitiveData(); if (storeLoaded) { return; } if (!loggedIn) { dispatch({ type: setStoreLoadedActionType }); return; } void (async () => { await Promise.all([ sensitiveDataHandled, mediaCacheContext?.evictCache(), ]); try { const { threads, messages, drafts, messageStoreThreads, reports, users, keyservers, communities, + integrityThreadHashes, } = await commCoreModule.getClientDBStore(); const threadInfosFromDB = threadStoreOpsHandlers.translateClientDBData(threads); const reportsFromDB = reportStoreOpsHandlers.translateClientDBData(reports); const usersFromDB = userStoreOpsHandlers.translateClientDBData(users); const keyserverInfosFromDB = keyserverStoreOpsHandlers.translateClientDBData(keyservers); const communityInfosFromDB = communityStoreOpsHandlers.translateClientDBData(communities); + const threadHashesFromDB = + integrityStoreOpsHandlers.translateClientDBData( + integrityThreadHashes, + ); dispatch({ type: setClientDBStoreActionType, payload: { drafts, messages, threadStore: { threadInfos: threadInfosFromDB }, currentUserID: currentLoggedInUserID, messageStoreThreads, reports: reportsFromDB, users: usersFromDB, keyserverInfos: keyserverInfosFromDB, communities: communityInfosFromDB, + threadHashes: threadHashesFromDB, }, }); } catch (setStoreException) { if (isTaskCancelledError(setStoreException)) { dispatch({ type: setStoreLoadedActionType }); return; } if (staffCanSee) { Alert.alert( 'Error setting threadStore or messageStore', getMessageForException(setStoreException) ?? '{no exception message}', ); } await recoverData(recoveryActionSources.sqliteLoadFailure); } })(); }, [ currentLoggedInUserID, handleSensitiveData, loggedIn, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, recoverData, callClearSensitiveData, mediaCacheContext, ]); return null; } export { SQLiteDataHandler, clearSensitiveData }; diff --git a/web/shared-worker/utils/store.js b/web/shared-worker/utils/store.js index 8a17b7d10..e44c3eb23 100644 --- a/web/shared-worker/utils/store.js +++ b/web/shared-worker/utils/store.js @@ -1,142 +1,151 @@ // @flow import { communityStoreOpsHandlers } from 'lib/ops/community-store-ops.js'; import { integrityStoreOpsHandlers } from 'lib/ops/integrity-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 { getCommSharedWorker } from '../shared-worker-provider.js'; async function getClientDBStore(): Promise { 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, + threadHashes: null, }; 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, ), }; } + if (data?.store?.integrityThreadHashes) { + result = { + ...result, + threadHashes: integrityStoreOpsHandlers.translateClientDBData( + data.store.integrityThreadHashes, + ), + }; + } return result; } async function processDBStoreOperations( storeOperations: StoreOperations, userID: null | string, ): Promise { const { draftStoreOperations, threadStoreOperations, reportStoreOperations, keyserverStoreOperations, communityStoreOperations, integrityStoreOperations, } = 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); const convertedIntegrityStoreOperations = integrityStoreOpsHandlers.convertOpsToClientDBOps(integrityStoreOperations); if ( convertedThreadStoreOperations.length === 0 && convertedReportStoreOperations.length === 0 && draftStoreOperations.length === 0 && convertedKeyserverStoreOperations.length === 0 && convertedCommunityStoreOperations.length === 0 && convertedIntegrityStoreOperations.length === 0 ) { return; } const sharedWorker = await getCommSharedWorker(); const isSupported = await sharedWorker.isSupported(); if (!isSupported) { return; } try { await sharedWorker.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations, reportStoreOperations: convertedReportStoreOperations, threadStoreOperations: convertedThreadStoreOperations, keyserverStoreOperations: convertedKeyserverStoreOperations, communityStoreOperations: convertedCommunityStoreOperations, integrityStoreOperations: convertedIntegrityStoreOperations, }, }); } catch (e) { console.log(e); if (canUseDatabase) { window.alert(e.message); if (threadStoreOperations.length > 0) { await sharedWorker.init({ clearDatabase: true }); location.reload(); } } } } export { getClientDBStore, processDBStoreOperations };