diff --git a/lib/shared/web-database.js b/lib/shared/web-database.js new file mode 100644 index 000000000..5d653d80f --- /dev/null +++ b/lib/shared/web-database.js @@ -0,0 +1,13 @@ +// @flow + +import { isStaff } from './staff-utils.js'; +import { isDev } from '../utils/dev-utils.js'; + +function canUseDatabaseOnWeb(userID: ?string): boolean { + if (!userID) { + return false; + } + return isDev || isStaff(userID); +} + +export { canUseDatabaseOnWeb }; diff --git a/web/redux/persist.js b/web/redux/persist.js index 5c67629e2..57528e30b 100644 --- a/web/redux/persist.js +++ b/web/redux/persist.js @@ -1,320 +1,318 @@ // @flow import invariant from 'invariant'; import { getStoredState, purgeStoredState, createTransform, } from 'redux-persist'; import storage from 'redux-persist/es/storage/index.js'; import type { Transform } from 'redux-persist/es/types.js'; import type { PersistConfig } from 'redux-persist/src/types.js'; import { createAsyncMigrate, type StorageMigrationFunction, } from 'lib/shared/create-async-migrate.js'; import type { KeyserverInfo, KeyserverStore, } from 'lib/types/keyserver-types.js'; import { cookieTypes } from 'lib/types/session-types.js'; import { defaultConnectionInfo, type ConnectionInfo, } 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 { generateIDSchemaMigrationOpsForDrafts, convertDraftStoreToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import commReduxStorageEngine from './comm-redux-storage-engine.js'; import type { AppState } from './redux-setup.js'; import { getDatabaseModule } from '../database/database-module-provider.js'; import { isSQLiteSupported } from '../database/utils/db-utils.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; declare var keyserverURL: string; const migrations = { [1]: async state => { const { primaryIdentityPublicKey, ...stateWithoutPrimaryIdentityPublicKey } = state; return { ...stateWithoutPrimaryIdentityPublicKey, cryptoStore: { primaryAccount: null, primaryIdentityKeys: null, notificationAccount: null, notificationIdentityKeys: null, }, }; }, [2]: async state => { 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(); if (!isDatabaseSupported) { return newState; } const stores = await databaseModule.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); invariant(stores?.store, 'Stores should exist'); await databaseModule.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations: generateIDSchemaMigrationOpsForDrafts( stores.store.drafts, ), }, }); return newState; }, [4]: async state => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [5]: async state => { const databaseModule = await getDatabaseModule(); const isDatabaseSupported = await databaseModule.isDatabaseSupported(); if (!isDatabaseSupported) { 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({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations }, }); return state; }, [6]: async state => ({ ...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, [ashoatKeyserverID]: { ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], cookie, }, }, }, }; }, [8]: async state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [9]: async state => ({ ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], urlPrefix: keyserverURL, }, }, }, }), [10]: async state => { const { keyserverInfos } = state.keyserverStore; const newKeyserverInfos = {}; for (const key in keyserverInfos) { newKeyserverInfos[key] = { ...keyserverInfos[key], connection: { ...defaultConnectionInfo }, updatesCurrentAsOf: 0, sessionID: null, }; } return { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, }; }, }; const persistWhitelist = [ 'enabledApps', 'cryptoStore', 'notifPermissionAlertInfo', 'commServicesAccessToken', 'keyserverStore', 'globalThemeInfo', ]; const rootKey = 'root'; const migrateStorageToSQLite: StorageMigrationFunction = async debug => { const databaseModule = await getDatabaseModule(); const isSupported = await databaseModule.isDatabaseSupported(); 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'); } // We need to simulate the keyserverStoreTransform for data stored in the // old local storage (because redux persist will only run it for the // sqlite storage which is empty in this case). // We don't just use keyserverStoreTransform.out(oldStorage) because // the transform might change in the future, but we need to treat // this code like migration code (it shouldn't change). if (oldStorage?._persist?.version === 4) { const defaultConnection = defaultConnectionInfo; return { ...oldStorage, keyserverStore: { ...oldStorage.keyserverStore, keyserverInfos: { ...oldStorage.keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...oldStorage.keyserverStore.keyserverInfos[ashoatKeyserverID], connection: { ...defaultConnection }, updatesCurrentAsOf: 0, sessionID: null, }, }, }, }; } return oldStorage; }; type PersistedKeyserverInfo = $Diff< KeyserverInfo, { +connection: ConnectionInfo, +updatesCurrentAsOf: number, +sessionID?: ?string, }, >; type PersistedKeyserverStore = { +keyserverInfos: { +[key: string]: PersistedKeyserverInfo }, }; const keyserverStoreTransform: Transform = createTransform( (state: KeyserverStore): PersistedKeyserverStore => { const keyserverInfos = {}; for (const key in state.keyserverInfos) { - const { connection, updatesCurrentAsOf, sessionID, ...rest } = - state.keyserverInfos[key]; + const { connection, sessionID, ...rest } = state.keyserverInfos[key]; keyserverInfos[key] = rest; } return { ...state, keyserverInfos, }; }, (state: PersistedKeyserverStore): KeyserverStore => { const keyserverInfos = {}; for (const key in state.keyserverInfos) { keyserverInfos[key] = { ...state.keyserverInfos[key], connection: { ...defaultConnectionInfo }, - updatesCurrentAsOf: 0, sessionID: null, }; } return { ...state, keyserverInfos, }; }, { whitelist: ['keyserverStore'] }, ); const persistConfig: PersistConfig = { key: rootKey, storage: commReduxStorageEngine, whitelist: isSQLiteSupported() ? persistWhitelist : [...persistWhitelist, 'draftStore'], migrate: (createAsyncMigrate( migrations, { debug: isDev }, migrateStorageToSQLite, ): any), version: 10, transforms: [keyserverStoreTransform], }; export { persistConfig }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index efd470f12..1f35ee8b1 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,325 +1,330 @@ // @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 { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import type { Shape } from 'lib/types/core.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, CalendarQuery } 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 { 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 { 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 { 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, updateNavInfoActionType, updateWindowDimensionsActionType, setInitialReduxState, } from './action-types.js'; import { reduceCommunityPickerStore } from './community-picker-reducer.js'; import { reduceCryptoStore, setCryptoStore } from './crypto-store-reducer.js'; import reduceNavInfo from './nav-reducer.js'; import { onStateDifference } from './redux-debug-utils.js'; import { getVisibility } from './visibility.js'; import { getDatabaseModule } from '../database/database-module-provider.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; import { type NavInfo } from '../types/nav-types.js'; import type { InitialReduxState } from '../types/redux-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, +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, +actualizedCalendarQuery: CalendarQuery, +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, }; export type Action = | BaseAction | { type: 'UPDATE_NAV_INFO', payload: Shape } | { 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 }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; if (action.type === setInitialReduxState) { const { userInfos, keyserverInfos, ...rest } = action.payload; const newKeyserverInfos = { ...state.keyserverStore.keyserverInfos }; for (const keyserverID in keyserverInfos) { + const newUpdatesCurrentAsOf = canUseDatabaseOnWeb(rest.currentUserInfo.id) + ? newKeyserverInfos[keyserverID].updatesCurrentAsOf + : keyserverInfos[keyserverID].updatesCurrentAsOf; newKeyserverInfos[keyserverID] = { ...newKeyserverInfos[keyserverID], ...keyserverInfos[keyserverID], + updatesCurrentAsOf: newUpdatesCurrentAsOf, }; } return validateState(oldState, { ...state, ...rest, userStore: { userInfos }, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, initialStateLoaded: true, }); } else 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, 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 !== setCryptoStore ) { const baseReducerResult = baseReducer(state, action, onStateDifference); state = baseReducerResult.state; const { storeOperations: { draftStoreOperations, reportStoreOperations }, } = baseReducerResult; if (draftStoreOperations.length > 0 || reportStoreOperations.length > 0) { (async () => { const databaseModule = await getDatabaseModule(); 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, ), 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] ) { const now = Date.now(); state = { ...state, threadActivityStore: { ...state.threadActivityStore, [activeThread]: { ...state.threadActivityStore[activeThread], lastNavigatedTo: now, }, }, }; } return state; }