diff --git a/keyserver/src/fetchers/report-fetchers.js b/keyserver/src/fetchers/report-fetchers.js index 636984507..2ade9e15d 100644 --- a/keyserver/src/fetchers/report-fetchers.js +++ b/keyserver/src/fetchers/report-fetchers.js @@ -1,107 +1,107 @@ // @flow -import { isStaff } from 'lib/shared/user-utils.js'; +import { isStaff } from 'lib/shared/staff-utils.js'; import { type FetchErrorReportInfosResponse, type FetchErrorReportInfosRequest, type ReduxToolsImport, reportTypes, } from 'lib/types/report-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; async function fetchErrorReportInfos( viewer: Viewer, request: FetchErrorReportInfosRequest, ): Promise { if (!viewer.loggedIn || !isStaff(viewer.userID)) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT r.id, r.user, r.platform, r.report, r.creation_time, u.username FROM reports r LEFT JOIN users u ON u.id = r.user `; if (request.cursor) { query.append(SQL`WHERE r.id < ${request.cursor} `); } query.append(SQL`ORDER BY r.id DESC`); const [result] = await dbQuery(query); const reports = []; const userInfos = {}; for (const row of result) { const viewerID = row.user.toString(); const report = JSON.parse(row.report); let { platformDetails } = report; if (!platformDetails) { platformDetails = { platform: row.platform, codeVersion: report.codeVersion, stateVersion: report.stateVersion, }; } reports.push({ id: row.id.toString(), viewerID, platformDetails, creationTime: row.creation_time, }); if (row.username) { userInfos[viewerID] = { id: viewerID, username: row.username, }; } } return { reports, userInfos: values(userInfos) }; } async function fetchReduxToolsImport( viewer: Viewer, id: string, ): Promise { if (!viewer.loggedIn || !isStaff(viewer.userID)) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, report, creation_time FROM reports WHERE id = ${id} AND type = ${reportTypes.ERROR} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const row = result[0]; const report = JSON.parse(row.report); const _persist = report.preloadedState._persist ? report.preloadedState._persist : {}; const navState = report.currentState && report.currentState.navState ? report.currentState.navState : undefined; return { preloadedState: { ...report.preloadedState, _persist: { ..._persist, // Setting this to false disables redux-persist rehydrated: false, }, navState, frozen: true, }, payload: report.actions, }; } export { fetchErrorReportInfos, fetchReduxToolsImport }; diff --git a/lib/reducers/report-store-reducer.js b/lib/reducers/report-store-reducer.js index 3cdfa2361..1b291ffa3 100644 --- a/lib/reducers/report-store-reducer.js +++ b/lib/reducers/report-store-reducer.js @@ -1,96 +1,96 @@ // @flow import { sendReportActionTypes, sendReportsActionTypes, queueReportsActionType, } from '../actions/report-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from '../actions/user-actions.js'; -import { isStaff } from '../shared/user-utils.js'; +import { isStaff } from '../shared/staff-utils.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ReportStore, defaultEnabledReports, defaultDevEnabledReports, type ClientReportCreationRequest, } from '../types/report-types.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { isDev } from '../utils/dev-utils.js'; import { isReportEnabled } from '../utils/report-utils.js'; export const updateReportsEnabledActionType = 'UPDATE_REPORTS_ENABLED'; export default function reduceReportStore( state: ReportStore, action: BaseAction, newInconsistencies: $ReadOnlyArray, ): ReportStore { const updatedReports = newInconsistencies.length > 0 ? [...state.queuedReports, ...newInconsistencies].filter(report => isReportEnabled(report, state.enabledReports), ) : state.queuedReports; if (action.type === updateReportsEnabledActionType) { const newEnabledReports = { ...state.enabledReports, ...action.payload }; const filteredReports = updatedReports.filter(report => isReportEnabled(report, newEnabledReports), ); return { queuedReports: filteredReports, enabledReports: newEnabledReports, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { queuedReports: [], enabledReports: isDev ? defaultDevEnabledReports : defaultEnabledReports, }; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { return { queuedReports: [], enabledReports: isStaff(action.payload.currentUserInfo.id) || isDev ? defaultDevEnabledReports : defaultEnabledReports, }; } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const unsentReports = updatedReports.filter( response => !payload.reports.includes(response), ); if (unsentReports.length === updatedReports.length) { return state; } return { ...state, queuedReports: unsentReports }; } else if (action.type === queueReportsActionType) { const { reports } = action.payload; const filteredReports = [...updatedReports, ...reports].filter(report => isReportEnabled(report, state.enabledReports), ); return { ...state, queuedReports: filteredReports, }; } return updatedReports !== state.queuedReports ? { ...state, queuedReports: updatedReports } : state; } diff --git a/lib/shared/staff-utils.js b/lib/shared/staff-utils.js new file mode 100644 index 000000000..e00609475 --- /dev/null +++ b/lib/shared/staff-utils.js @@ -0,0 +1,19 @@ +// @flow + +import bots from '../facts/bots.js'; +import staff from '../facts/staff.js'; + +function isStaff(userID: string): boolean { + if (staff.includes(userID)) { + return true; + } + for (const key in bots) { + const bot = bots[key]; + if (userID === bot.userID) { + return true; + } + } + return false; +} + +export { isStaff }; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index afa182205..f4389918c 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,61 +1,46 @@ // @flow import { memberHasAdminPowers } from './thread-utils.js'; -import bots from '../facts/bots.js'; -import staff from '../facts/staff.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { - ServerThreadInfo, RawThreadInfo, + ServerThreadInfo, ThreadInfo, } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; function stringForUser(user: { +username?: ?string, +isViewer?: ?boolean, ... }): string { if (user.isViewer) { return 'you'; } return stringForUserExplicit(user); } function stringForUserExplicit(user: { +username: ?string, ... }): string { if (user.username) { return user.username; } else { return 'anonymous'; } } -function isStaff(userID: string): boolean { - if (staff.includes(userID)) { - return true; - } - for (const key in bots) { - const bot = bots[key]; - if (userID === bot.userID) { - return true; - } - } - return false; -} - function useKeyserverAdmin( community: ThreadInfo | RawThreadInfo | ServerThreadInfo, ): ?UserInfo { const userInfos = useSelector(state => state.userStore.userInfos); // This hack only works as long as there is only one admin // Linear task to revert this: // https://linear.app/comm/issue/ENG-1707/revert-fix-getting-the-keyserver-admin-info const admin = community.members.find(memberHasAdminPowers); const adminUserInfo = admin ? userInfos[admin.id] : undefined; const [adminUserInfoWithENSName] = useENSNames([adminUserInfo]); return adminUserInfoWithENSName; } -export { stringForUser, stringForUserExplicit, isStaff, useKeyserverAdmin }; +export { stringForUser, stringForUserExplicit, useKeyserverAdmin }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 768695537..7fb8a38d5 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,494 +1,494 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import thunk from 'redux-thunk'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { processThreadStoreOperations } from 'lib/reducers/thread-reducer.js'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils.js'; -import { isStaff } from 'lib/shared/user-utils.js'; +import { isStaff } from 'lib/shared/staff-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { Dispatch, BaseAction } from 'lib/types/redux-types.js'; import { rehydrateActionType } from 'lib/types/redux-types.js'; import type { SetSessionPayload } from 'lib/types/session-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import type { ThreadStoreOperation } from 'lib/types/thread-types.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { resetUserStateActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, setStoreLoadedActionType, type Action, } from './action-types.js'; import { remoteReduxDevServerConfig } from './dev-tools.js'; import { defaultDimensionsInfo } from './dimensions-updater.react.js'; import { persistConfig, setPersistor } from './persist.js'; import { processDBStoreOperations } from './redux-utils.js'; import type { AppState } from './state-types.js'; import { defaultNavInfo } from '../navigation/default-state.js'; import { getGlobalNavContext } from '../navigation/icky-global.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import reactotron from '../reactotron.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultConnectivityInfo } from '../types/connectivity.js'; import { defaultGlobalThemeInfo } from '../types/themes.js'; import { isStaffRelease } from '../utils/staff-utils.js'; import { defaultURLPrefix, natNodeServer, setCustomServer, getDevServerHostname, } from '../utils/url-utils.js'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, draftStore: { drafts: {} }, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, storeLoaded: false, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix, customServer: natNodeServer, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [], }, nextLocalID: 0, _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, userPolicies: {}, commServicesAccessToken: null, }: AppState); function reducer(state: AppState = defaultState, action: Action) { if (action.type === setReduxStateActionType) { return action.payload.state; } // We want to alert staff/developers if there's a difference between the keys // we expect to see REHYDRATED and the keys that are actually REHYDRATED. // Context: https://linear.app/comm/issue/ENG-2127/ if ( action.type === rehydrateActionType && (__DEV__ || isStaffRelease || (state.currentUserInfo && state.currentUserInfo.id && isStaff(state.currentUserInfo.id))) ) { // 1. Construct set of keys expected to be REHYDRATED const defaultKeys = Object.keys(defaultState); const expectedKeys = defaultKeys.filter( each => !persistConfig.blacklist.includes(each), ); const expectedKeysSet = new Set(expectedKeys); // 2. Construct set of keys actually REHYDRATED const rehydratedKeys = Object.keys(action.payload ?? {}); const rehydratedKeysSet = new Set(rehydratedKeys); // 3. Determine the difference between the two sets const expectedKeysNotRehydrated = expectedKeys.filter( each => !rehydratedKeysSet.has(each), ); const rehydratedKeysNotExpected = rehydratedKeys.filter( each => !expectedKeysSet.has(each), ); // 4. Display alerts with the differences between the two sets if (expectedKeysNotRehydrated.length > 0) { Alert.alert( `EXPECTED KEYS NOT REHYDRATED: ${JSON.stringify( expectedKeysNotRehydrated, )}`, ); } if (rehydratedKeysNotExpected.length > 0) { Alert.alert( `REHYDRATED KEYS NOT EXPECTED: ${JSON.stringify( rehydratedKeysNotExpected, )}`, ); } } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.logInActionSource, )) || ((action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success) && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.logInActionSource, )) ) { return state; } if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { const updatedThreads = { [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }; state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, ...updatedThreads, }, }, }; processDBStoreOperations({ draftStoreOperations: [], messageStoreOperations: [ { type: 'replace_threads', payload: { threads: updatedThreads, }, }, ], threadStoreOperations: [], }); } return state; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } if (action.type === setStoreLoadedActionType) { return { ...state, storeLoaded: true, }; } if (action.type === setClientDBStoreActionType) { state = { ...state, storeLoaded: true, }; const currentLoggedInUserID = state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id; const actionCurrentLoggedInUserID = action.payload.currentUserID; if ( !currentLoggedInUserID || !actionCurrentLoggedInUserID || actionCurrentLoggedInUserID !== currentLoggedInUserID ) { // If user is logged out now, was logged out at the time action was // dispatched or their ID changed between action dispatch and a // call to reducer we ignore the SQLite data since it is not valid return state; } } const baseReducerResult = baseReducer(state, (action: BaseAction)); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { draftStoreOperations, threadStoreOperations, messageStoreOperations, } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; processDBStoreOperations({ draftStoreOperations, messageStoreOperations, threadStoreOperations: threadStoreOperationsWithUnreadFix, }); return state; } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how to ' + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', 'We’re sorry, but your session was invalidated by the server. ' + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. type FixUnreadActiveThreadResult = { +state: AppState, +threadStoreOperations: $ReadOnlyArray, }; function fixUnreadActiveThread( state: AppState, action: *, ): FixUnreadActiveThreadResult { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( !activeThread || !state.threadStore.threadInfos[activeThread]?.currentUser.unread || (NativeAppState.currentState !== 'active' && (appLastBecameInactive + 10000 >= Date.now() || backgroundActionTypes.has(action.type))) ) { return { state, threadStoreOperations: [] }; } const updatedActiveThreadInfo = { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src/index.js'); composeFunc = composeWithDevTools({ name: 'Redux', hostname: getDevServerHostname(), ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/native/utils/staff-utils.js b/native/utils/staff-utils.js index 66d0e225f..7b5580309 100644 --- a/native/utils/staff-utils.js +++ b/native/utils/staff-utils.js @@ -1,21 +1,21 @@ // @flow -import { isStaff } from 'lib/shared/user-utils.js'; +import { isStaff } from 'lib/shared/staff-utils.js'; import { useSelector } from '../redux/redux-utils.js'; const isStaffRelease = false; function useIsCurrentUserStaff(): boolean { const isCurrentUserStaff = useSelector(state => state.currentUserInfo?.id ? isStaff(state.currentUserInfo.id) : false, ); return isCurrentUserStaff; } function useStaffCanSee(): boolean { const isCurrentUserStaff = useIsCurrentUserStaff(); return __DEV__ || isStaffRelease || isCurrentUserStaff; } export { isStaffRelease, useIsCurrentUserStaff, useStaffCanSee }; diff --git a/web/database/utils/db-utils.js b/web/database/utils/db-utils.js index 634ce0817..cae88c347 100644 --- a/web/database/utils/db-utils.js +++ b/web/database/utils/db-utils.js @@ -1,48 +1,48 @@ // @flow import { detect as detectBrowser } from 'detect-browser'; import type { QueryExecResult } from 'sql.js'; -import { isStaff } from 'lib/shared/user-utils.js'; +import { isStaff } from 'lib/shared/staff-utils.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { DB_SUPPORTED_BROWSERS, DB_SUPPORTED_OS } from './constants.js'; function parseSQLiteQueryResult(result: QueryExecResult): T[] { const { columns, values } = result; return values.map(rowResult => { const row: any = Object.fromEntries( columns.map((key, index) => [key, rowResult[index]]), ); return row; }); } // NOTE: sql.js has behavior that when there are multiple statements in query // e.g. "statement1; statement2; statement3;" // and statement2 will not return anything, the result will be: // [result1, result3], not [result1, undefined, result3] function parseMultiStatementSQLiteResult( rawResult: $ReadOnlyArray, ): T[][] { return rawResult.map((queryResult: QueryExecResult) => parseSQLiteQueryResult(queryResult), ); } function isSQLiteSupported(currentLoggedInUserID: ?string): boolean { if (!currentLoggedInUserID) { return false; } if (!isDev && (!currentLoggedInUserID || !isStaff(currentLoggedInUserID))) { return false; } const browser = detectBrowser(); return ( DB_SUPPORTED_OS.includes(browser.os) && DB_SUPPORTED_BROWSERS.includes(browser.name) ); } export { parseMultiStatementSQLiteResult, isSQLiteSupported };