diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 0e33b7bb5..d71d65661 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,216 +1,223 @@ // @flow import type { BaseAction } from '../types/redux-types'; import type { CurrentUserInfo, UserInfo, UserInfos } from '../types/user-types'; import { updateTypes, processUpdatesActionType } from '../types/update-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import invariant from 'invariant'; import _keyBy from 'lodash/fp/keyBy'; import _isEqual from 'lodash/fp/isEqual'; import { setNewSessionActionType } from '../utils/action-utils'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, resetPasswordActionTypes, changeUserSettingsActionTypes, searchUsersActionTypes, } from '../actions/user-actions'; import { joinThreadActionTypes } from '../actions/thread-actions'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, } from '../actions/message-actions'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { for (let update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo)(state) ) { return update.currentUserInfo; } } } else if (action.type === changeUserSettingsActionTypes.success) { invariant( state && !state.anonymous, "can't change settings if not logged in", ); const email = action.payload.email; if (!email) { return state; } return { id: state.id, username: state.username, email: email, emailVerified: false, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if ( checkStateRequest && checkStateRequest.stateChanges && checkStateRequest.stateChanges.currentUserInfo && !_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state) ) { return checkStateRequest.stateChanges.currentUserInfo; } } return state; } function reduceUserInfos( state: { [id: string]: UserInfo }, action: BaseAction, ): UserInfos { if ( action.type === joinThreadActionTypes.success || action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success || action.type === fetchEntriesActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === searchUsersActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 const updated = { ...state, ...newUserInfos }; if (!_isEqual(state)(updated)) { return updated; } } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state).length === 0) { return state; } return {}; } else if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); if (!_isEqual(state)(newUserInfos)) { return newUserInfos; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 const updated = { ...state, ...newUserInfos }; for (let update of action.payload.updatesResult.newUpdates) { if (update.type === updateTypes.DELETE_ACCOUNT) { delete updated[update.deletedUserID]; } } if (!_isEqual(state)(updated)) { return updated; } } else if ( action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success || action.type === restoreEntryActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.updatesResult.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 const updated = { ...state, ...newUserInfos }; if (!_isEqual(state)(updated)) { return updated; } } else if (action.type === deleteEntryActionTypes.success && action.payload) { const { updatesResult } = action.payload; const newUserInfos = _keyBy(userInfo => userInfo.id)( updatesResult.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 const updated = { ...state, ...newUserInfos }; if (!_isEqual(state)(updated)) { return updated; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); - if ( - checkStateRequest && - checkStateRequest.stateChanges && - checkStateRequest.stateChanges.userInfos - ) { - const newUserInfos = checkStateRequest.stateChanges.userInfos; - const newUserInfoObject = _keyBy(userInfo => userInfo.id)(newUserInfos); - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const updated = { ...state, ...newUserInfoObject }; - if (!_isEqual(state)(updated)) { - return updated; + if (!checkStateRequest || !checkStateRequest.stateChanges) { + return state; + } + const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; + if (!userInfos && !deleteUserInfoIDs) { + return state; + } + + const newUserInfos = { ...state }; + if (userInfos) { + for (const userInfo of userInfos) { + newUserInfos[userInfo.id] = userInfo; + } + } + if (deleteUserInfoIDs) { + for (const deleteUserInfoID of deleteUserInfoIDs) { + delete newUserInfos[deleteUserInfoID]; } } + return newUserInfos; } return state; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index ce9d1c484..0361e8b9c 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,162 +1,180 @@ // @flow import type { AppState } from '../types/redux-types'; import { serverRequestTypes, type ServerRequest, type ClientClientResponse, } from '../types/request-types'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; -import type { CurrentUserInfo } from '../types/user-types'; +import type { CurrentUserInfo, UserInfo } from '../types/user-types'; import type { RawThreadInfo } from '../types/thread-types'; import type { SessionState } from '../types/session-types'; import type { ClientThreadInconsistencyReportCreationRequest, ClientEntryInconsistencyReportCreationRequest, ClientReportCreationRequest, } from '../types/report-types'; import { createSelector } from 'reselect'; import { getConfig } from '../utils/config'; import { serverEntryInfo, serverEntryInfosObject, filterRawEntryInfosByCalendarQuery, } from '../shared/entry-utils'; import { values, hash } from '../utils/objects'; import { currentCalendarQuery } from './nav-selectors'; import threadWatcher from '../shared/thread-watcher'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.inconsistencyReports, (state: AppState) => state.entryStore.inconsistencyReports, (state: AppState) => state.queuedReports, ( threadInconsistencyReports: $ReadOnlyArray, entryInconsistencyReports: $ReadOnlyArray, mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => [ ...threadInconsistencyReports, ...entryInconsistencyReports, ...mainQueuedReports, ], ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, + (state: AppState) => state.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( threadInfos: { [id: string]: RawThreadInfo }, entryInfos: { [id: string]: RawEntryInfo }, + userInfos: { [id: string]: UserInfo }, currentUserInfo: ?CurrentUserInfo, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ): $ReadOnlyArray => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( request => request.type === serverRequestTypes.PLATFORM_DETAILS, ); for (let 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 filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(entryInfos)), calendarQuery(calendarActive), ); const hashResults = {}; for (let key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; let hashValue; if (key === 'threadInfos') { hashValue = hash(threadInfos); } else if (key === 'entryInfos') { hashValue = hash(filteredEntryInfos); + } else if (key === 'userInfos') { + hashValue = hash(userInfos); } else if (key === 'currentUserInfo') { hashValue = hash(currentUserInfo); } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); hashValue = hash(threadInfos[threadID]); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); let rawEntryInfo = filteredEntryInfos[entryID]; if (rawEntryInfo) { rawEntryInfo = serverEntryInfo(rawEntryInfo); } hashValue = hash(rawEntryInfo); + } else if (key.startsWith('userInfo|')) { + const [, userID] = key.split('|'); + hashValue = hash(userInfos[userID]); + } else { + continue; } hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; if (failUnmentioned && failUnmentioned.threadInfos) { for (let threadID in threadInfos) { const key = `threadInfo|${threadID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.entryInfos) { for (let entryID in filteredEntryInfos) { const key = `entryInfo|${entryID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } + if (failUnmentioned && failUnmentioned.userInfos) { + for (let userID in userInfos) { + const key = `userInfo|${userID}`; + const hashResult = hashResults[key]; + if (hashResult === null || hashResult === undefined) { + hashResults[key] = false; + } + } + } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } } return clientResponses; }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/types/request-types.js b/lib/types/request-types.js index 882620a8d..c7df886cc 100644 --- a/lib/types/request-types.js +++ b/lib/types/request-types.js @@ -1,156 +1,158 @@ // @flow import type { Platform, PlatformDetails } from './device-types'; import type { RawThreadInfo } from './thread-types'; import type { RawEntryInfo, CalendarQuery } from './entry-types'; import { type ActivityUpdate } from './activity-types'; import type { CurrentUserInfo, AccountUserInfo } from './user-types'; import type { ThreadInconsistencyReportShape, EntryInconsistencyReportShape, ClientThreadInconsistencyReportShape, ClientEntryInconsistencyReportShape, } from './report-types'; import invariant from 'invariant'; // "Server requests" are requests for information that the server delivers to // clients. Clients then respond to those requests with a "client response". export const serverRequestTypes = Object.freeze({ PLATFORM: 0, DEVICE_TOKEN: 1, THREAD_INCONSISTENCY: 2, PLATFORM_DETAILS: 3, INITIAL_ACTIVITY_UPDATE: 4, ENTRY_INCONSISTENCY: 5, CHECK_STATE: 6, INITIAL_ACTIVITY_UPDATES: 7, }); type ServerRequestType = $Values; export function assertServerRequestType( serverRequestType: number, ): ServerRequestType { invariant( serverRequestType === 0 || serverRequestType === 1 || serverRequestType === 2 || serverRequestType === 3 || serverRequestType === 4 || serverRequestType === 5 || serverRequestType === 6 || serverRequestType === 7, 'number is not ServerRequestType enum', ); return serverRequestType; } type PlatformServerRequest = {| type: 0, |}; type PlatformClientResponse = {| type: 0, platform: Platform, |}; type DeviceTokenServerRequest = {| type: 1, |}; type DeviceTokenClientResponse = {| type: 1, deviceToken: string, |}; export type ThreadInconsistencyClientResponse = {| ...ThreadInconsistencyReportShape, type: 2, |}; type PlatformDetailsServerRequest = {| type: 3, |}; type PlatformDetailsClientResponse = {| type: 3, platformDetails: PlatformDetails, |}; type InitialActivityUpdateClientResponse = {| type: 4, threadID: string, |}; export type EntryInconsistencyClientResponse = {| type: 5, ...EntryInconsistencyReportShape, |}; export type CheckStateServerRequest = {| type: 6, hashesToCheck: { [key: string]: number }, failUnmentioned?: $Shape<{| threadInfos: boolean, entryInfos: boolean, + userInfos: boolean, |}>, stateChanges?: $Shape<{| rawThreadInfos: RawThreadInfo[], rawEntryInfos: RawEntryInfo[], currentUserInfo: CurrentUserInfo, userInfos: AccountUserInfo[], deleteThreadIDs: string[], deleteEntryIDs: string[], + deleteUserInfoIDs: string[], |}>, |}; type CheckStateClientResponse = {| type: 6, hashResults: { [key: string]: boolean }, |}; type InitialActivityUpdatesClientResponse = {| type: 7, activityUpdates: $ReadOnlyArray, |}; export type ServerRequest = | PlatformServerRequest | DeviceTokenServerRequest | PlatformDetailsServerRequest | CheckStateServerRequest; export type ClientResponse = | PlatformClientResponse | DeviceTokenClientResponse | ThreadInconsistencyClientResponse | PlatformDetailsClientResponse | InitialActivityUpdateClientResponse | EntryInconsistencyClientResponse | CheckStateClientResponse | InitialActivityUpdatesClientResponse; // This is just the client variant of ClientResponse. The server needs to handle // multiple client versions so the type supports old versions of certain client // responses, but the client variant only need to support the latest version. type ClientThreadInconsistencyClientResponse = {| ...ClientThreadInconsistencyReportShape, type: 2, |}; type ClientEntryInconsistencyClientResponse = {| type: 5, ...ClientEntryInconsistencyReportShape, |}; export type ClientClientResponse = | PlatformClientResponse | DeviceTokenClientResponse | ClientThreadInconsistencyClientResponse | PlatformDetailsClientResponse | InitialActivityUpdateClientResponse | ClientEntryInconsistencyClientResponse | CheckStateClientResponse | InitialActivityUpdatesClientResponse; export type ClientInconsistencyResponse = | ClientThreadInconsistencyClientResponse | ClientEntryInconsistencyClientResponse; export const processServerRequestsActionType = 'PROCESS_SERVER_REQUESTS'; export type ProcessServerRequestsPayload = {| serverRequests: $ReadOnlyArray, calendarQuery: CalendarQuery, |}; diff --git a/server/src/fetchers/user-fetchers.js b/server/src/fetchers/user-fetchers.js index d1b37b649..e51260511 100644 --- a/server/src/fetchers/user-fetchers.js +++ b/server/src/fetchers/user-fetchers.js @@ -1,121 +1,145 @@ // @flow import type { UserInfos, CurrentUserInfo, LoggedInUserInfo, } from 'lib/types/user-types'; import type { Viewer } from '../session/viewer'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL } from '../database'; async function fetchUserInfos(userIDs: string[]): Promise { if (userIDs.length <= 0) { return {}; } const query = SQL` SELECT id, username FROM users WHERE id IN (${userIDs}) `; const [result] = await dbQuery(query); const userInfos = {}; for (let row of result) { const id = row.id.toString(); userInfos[id] = { id, username: row.username, }; } for (let userID of userIDs) { if (!userInfos[userID]) { userInfos[userID] = { id: userID, username: null, }; } } return userInfos; } +async function fetchKnownUserInfos(viewer: Viewer) { + if (!viewer.loggedIn) { + throw new ServerError('not_logged_in'); + } + const query = SQL` + SELECT DISTINCT u.id, u.username FROM relationships_undirected r + LEFT JOIN users u ON r.user1 = u.id OR r.user2 = u.id + WHERE (r.user1 = ${viewer.userID} OR r.user2 = ${viewer.userID}) + `; + const [result] = await dbQuery(query); + + const userInfos = {}; + for (let row of result) { + const id = row.id.toString(); + userInfos[id] = { + id, + username: row.username, + }; + } + + return userInfos; +} + async function verifyUserIDs( userIDs: $ReadOnlyArray, ): Promise { if (userIDs.length === 0) { return []; } const query = SQL`SELECT id FROM users WHERE id IN (${userIDs})`; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function verifyUserOrCookieIDs( ids: $ReadOnlyArray, ): Promise { if (ids.length === 0) { return []; } const query = SQL` SELECT id FROM users WHERE id IN (${ids}) UNION SELECT id FROM cookies WHERE id IN (${ids}) `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function fetchCurrentUserInfo(viewer: Viewer): Promise { if (!viewer.loggedIn) { return { id: viewer.cookieID, anonymous: true }; } const currentUserInfos = await fetchLoggedInUserInfos([viewer.userID]); if (currentUserInfos.length === 0) { throw new ServerError('unknown_error'); } return currentUserInfos[0]; } async function fetchLoggedInUserInfos( userIDs: $ReadOnlyArray, ): Promise { const query = SQL` SELECT id, username, email, email_verified FROM users WHERE id IN (${userIDs}) `; const [result] = await dbQuery(query); return result.map(row => ({ id: row.id.toString(), username: row.username, email: row.email, emailVerified: !!row.email_verified, })); } async function fetchAllUserIDs(): Promise { const query = SQL`SELECT id FROM users`; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function fetchUsername(id: string): Promise { const query = SQL`SELECT username FROM users WHERE id = ${id}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return row.username; } export { fetchUserInfos, verifyUserIDs, verifyUserOrCookieIDs, fetchCurrentUserInfo, fetchLoggedInUserInfos, fetchAllUserIDs, fetchUsername, + fetchKnownUserInfos, }; diff --git a/server/src/responders/ping-responders.js b/server/src/responders/ping-responders.js index 23f140c98..329476979 100644 --- a/server/src/responders/ping-responders.js +++ b/server/src/responders/ping-responders.js @@ -1,737 +1,793 @@ // @flow import { type PingRequest, type PingResponse, pingResponseTypes, } from 'lib/types/ping-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import type { Viewer } from '../session/viewer'; import { serverRequestTypes, type ThreadInconsistencyClientResponse, type EntryInconsistencyClientResponse, type ClientResponse, type ServerRequest, type CheckStateServerRequest, } from 'lib/types/request-types'; import { isDeviceType, assertDeviceType } from 'lib/types/device-types'; import { reportTypes, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, } from 'lib/types/report-types'; import type { CalendarQuery, DeltaEntryInfosResponse, } from 'lib/types/entry-types'; import { sessionCheckFrequency } from 'lib/types/session-types'; import type { UpdateActivityResult } from 'lib/types/activity-types'; import type { SessionUpdate } from '../updaters/session-updaters'; import t from 'tcomb'; import invariant from 'invariant'; import { ServerError } from 'lib/utils/errors'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils'; import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils'; import { promiseAll } from 'lib/utils/promises'; import { values, hash } from 'lib/utils/objects'; import { usersInRawEntryInfos, serverEntryInfo, serverEntryInfosObject, } from 'lib/shared/entry-utils'; import { usersInThreadInfo } from 'lib/shared/thread-utils'; import { validateInput, tShape, tPlatform, tPlatformDetails, } from '../utils/validation-utils'; import { entryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; import { fetchMessageInfosSince } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchEntryInfos, fetchEntryInfosByID, fetchEntriesForSession, } from '../fetchers/entry-fetchers'; import { updateActivityTime, activityUpdater, } from '../updaters/activity-updaters'; import { fetchCurrentUserInfo, fetchUserInfos, + fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { fetchUpdateInfos } from '../fetchers/update-fetchers'; import { setNewSession, setCookiePlatform, setCookiePlatformDetails, } from '../session/cookies'; import { deviceTokenUpdater } from '../updaters/device-token-updaters'; import createReport from '../creators/report-creator'; import { commitSessionUpdate } from '../updaters/session-updaters'; import { compareNewCalendarQuery } from '../updaters/entry-updaters'; import { deleteUpdatesBeforeTimeTargettingSession } from '../deleters/update-deleters'; import { activityUpdatesInputValidator } from './activity-responders'; import { SQL } from '../database'; import { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, } from './report-responders'; const clientResponseInputValidator = t.union([ tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM', x => x === serverRequestTypes.PLATFORM, ), platform: tPlatform, }), tShape({ type: t.irreducible( 'serverRequestTypes.DEVICE_TOKEN', x => x === serverRequestTypes.DEVICE_TOKEN, ), deviceToken: t.String, }), tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.THREAD_INCONSISTENCY', x => x === serverRequestTypes.THREAD_INCONSISTENCY, ), }), tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.ENTRY_INCONSISTENCY', x => x === serverRequestTypes.ENTRY_INCONSISTENCY, ), }), tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM_DETAILS', x => x === serverRequestTypes.PLATFORM_DETAILS, ), platformDetails: tPlatformDetails, }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_ACTIVITY_UPDATE', x => x === serverRequestTypes.INITIAL_ACTIVITY_UPDATE, ), threadID: t.String, }), tShape({ type: t.irreducible( 'serverRequestTypes.CHECK_STATE', x => x === serverRequestTypes.CHECK_STATE, ), hashResults: t.dict(t.String, t.Boolean), }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_ACTIVITY_UPDATES', x => x === serverRequestTypes.INITIAL_ACTIVITY_UPDATES, ), activityUpdates: activityUpdatesInputValidator, }), ]); const pingRequestInputValidator = tShape({ type: t.maybe(t.Number), calendarQuery: entryQueryInputValidator, lastPing: t.maybe(t.Number), // deprecated messagesCurrentAsOf: t.maybe(t.Number), updatesCurrentAsOf: t.maybe(t.Number), watchedIDs: t.list(t.String), clientResponses: t.maybe(t.list(clientResponseInputValidator)), }); async function pingResponder( viewer: Viewer, input: any, ): Promise { const request: PingRequest = input; request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); await validateInput(viewer, pingRequestInputValidator, request); let clientMessagesCurrentAsOf; if ( request.messagesCurrentAsOf !== null && request.messagesCurrentAsOf !== undefined ) { clientMessagesCurrentAsOf = request.messagesCurrentAsOf; } else if (request.lastPing !== null && request.lastPing !== undefined) { clientMessagesCurrentAsOf = request.lastPing; } if ( clientMessagesCurrentAsOf === null || clientMessagesCurrentAsOf === undefined ) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; await verifyCalendarQueryThreadIDs(calendarQuery); const oldUpdatesCurrentAsOf = request.updatesCurrentAsOf; const sessionInitializationResult = await initializeSession( viewer, calendarQuery, oldUpdatesCurrentAsOf, ); const threadCursors = {}; for (let watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; const [ messagesResult, { serverRequests, stateCheckStatus }, ] = await Promise.all([ fetchMessageInfosSince( viewer, threadSelectionCriteria, clientMessagesCurrentAsOf, defaultNumberPerThread, ), processClientResponses(viewer, request.clientResponses), ]); const incrementalUpdate = request.type === pingResponseTypes.INCREMENTAL && sessionInitializationResult.sessionContinued; const messagesCurrentAsOf = mostRecentMessageTimestamp( messagesResult.rawMessageInfos, clientMessagesCurrentAsOf, ); const promises = {}; promises.activityUpdate = updateActivityTime(viewer); if ( viewer.loggedIn && oldUpdatesCurrentAsOf !== null && oldUpdatesCurrentAsOf !== undefined ) { promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargettingSession( viewer, oldUpdatesCurrentAsOf, ); promises.fetchUpdateResult = fetchUpdateInfos( viewer, oldUpdatesCurrentAsOf, calendarQuery, ); } if (incrementalUpdate && stateCheckStatus) { promises.stateCheck = checkState(viewer, stateCheckStatus, calendarQuery); } const { fetchUpdateResult, stateCheck } = await promiseAll(promises); let updateUserInfos = {}, updatesResult = null; if (fetchUpdateResult) { invariant( oldUpdatesCurrentAsOf !== null && oldUpdatesCurrentAsOf !== undefined, 'should be set', ); updateUserInfos = fetchUpdateResult.userInfos; const { updateInfos } = fetchUpdateResult; const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp( [...updateInfos], oldUpdatesCurrentAsOf, ); updatesResult = { newUpdates: updateInfos, currentAsOf: newUpdatesCurrentAsOf, }; } if (incrementalUpdate) { invariant(sessionInitializationResult.sessionContinued, 'should be set'); let sessionUpdate = sessionInitializationResult.sessionUpdate; if (stateCheck && stateCheck.sessionUpdate) { sessionUpdate = { ...sessionUpdate, ...stateCheck.sessionUpdate }; } await commitSessionUpdate(viewer, sessionUpdate); if (stateCheck && stateCheck.checkStateRequest) { serverRequests.push(stateCheck.checkStateRequest); } // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 const incrementalUserInfos = values({ ...messagesResult.userInfos, ...updateUserInfos, ...sessionInitializationResult.deltaEntryInfoResult.userInfos, }); const response: PingResponse = { type: pingResponseTypes.INCREMENTAL, messagesResult: { rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, currentAsOf: messagesCurrentAsOf, }, deltaEntryInfos: sessionInitializationResult.deltaEntryInfoResult.rawEntryInfos, userInfos: incrementalUserInfos, serverRequests, }; if (updatesResult) { response.updatesResult = updatesResult; } return response; } const [threadsResult, entriesResult, currentUserInfo] = await Promise.all([ fetchThreadInfos(viewer), fetchEntryInfos(viewer, [calendarQuery]), fetchCurrentUserInfo(viewer), ]); const deltaEntriesUserInfos = sessionInitializationResult.sessionContinued ? sessionInitializationResult.deltaEntryInfoResult.userInfos : undefined; const userInfos = values({ ...messagesResult.userInfos, ...entriesResult.userInfos, ...threadsResult.userInfos, ...updateUserInfos, ...deltaEntriesUserInfos, }); const response: PingResponse = { type: pingResponseTypes.FULL, threadInfos: threadsResult.threadInfos, currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, messagesCurrentAsOf, serverTime: messagesCurrentAsOf, rawEntryInfos: entriesResult.rawEntryInfos, userInfos, serverRequests, }; if (updatesResult) { response.updatesResult = updatesResult; } if (sessionInitializationResult.sessionContinued) { // This will only happen when the client requests a FULL response, which // doesn't occur in recent client versions. The client will use the result // to identify entry inconsistencies. response.deltaEntryInfos = sessionInitializationResult.deltaEntryInfoResult.rawEntryInfos; } return response; } type StateCheckStatus = | {| status: 'state_validated' |} | {| status: 'state_invalid', invalidKeys: $ReadOnlyArray |} | {| status: 'state_check' |}; type ProcessClientResponsesResult = {| serverRequests: ServerRequest[], stateCheckStatus: ?StateCheckStatus, activityUpdateResult: ?UpdateActivityResult, |}; async function processClientResponses( viewer: Viewer, clientResponses: ?$ReadOnlyArray, ): Promise { let viewerMissingPlatform = !viewer.platform; const { platformDetails } = viewer; let viewerMissingPlatformDetails = !platformDetails || (isDeviceType(viewer.platform) && (platformDetails.codeVersion === null || platformDetails.codeVersion === undefined || platformDetails.stateVersion === null || platformDetails.stateVersion === undefined)); let viewerMissingDeviceToken = isDeviceType(viewer.platform) && viewer.loggedIn && !viewer.deviceToken; const promises = []; let activityUpdates = []; let stateCheckStatus = null; if (clientResponses) { const clientSentPlatformDetails = clientResponses.some( response => response.type === serverRequestTypes.PLATFORM_DETAILS, ); for (let clientResponse of clientResponses) { if ( clientResponse.type === serverRequestTypes.PLATFORM && !clientSentPlatformDetails ) { promises.push(setCookiePlatform(viewer, clientResponse.platform)); viewerMissingPlatform = false; if (!isDeviceType(clientResponse.platform)) { viewerMissingPlatformDetails = false; } } else if (clientResponse.type === serverRequestTypes.DEVICE_TOKEN) { promises.push( deviceTokenUpdater(viewer, { deviceToken: clientResponse.deviceToken, deviceType: assertDeviceType(viewer.platform), }), ); viewerMissingDeviceToken = false; } else if ( clientResponse.type === serverRequestTypes.THREAD_INCONSISTENCY ) { promises.push(recordThreadInconsistency(viewer, clientResponse)); } else if ( clientResponse.type === serverRequestTypes.ENTRY_INCONSISTENCY ) { promises.push(recordEntryInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.PLATFORM_DETAILS) { promises.push( setCookiePlatformDetails(viewer, clientResponse.platformDetails), ); viewerMissingPlatform = false; viewerMissingPlatformDetails = false; } else if ( clientResponse.type === serverRequestTypes.INITIAL_ACTIVITY_UPDATE ) { promises.push( activityUpdater(viewer, { updates: [{ focus: true, threadID: clientResponse.threadID }], }), ); } else if ( clientResponse.type === serverRequestTypes.INITIAL_ACTIVITY_UPDATES ) { activityUpdates = [ ...activityUpdates, ...clientResponse.activityUpdates, ]; } else if (clientResponse.type === serverRequestTypes.CHECK_STATE) { const invalidKeys = []; for (let key in clientResponse.hashResults) { const result = clientResponse.hashResults[key]; if (!result) { invalidKeys.push(key); } } stateCheckStatus = invalidKeys.length > 0 ? { status: 'state_invalid', invalidKeys } : { status: 'state_validated' }; } } } let activityUpdateResult; if (activityUpdates.length > 0 || promises.length > 0) { [activityUpdateResult] = await Promise.all([ activityUpdates.length > 0 ? activityUpdater(viewer, { updates: activityUpdates }) : undefined, promises.length > 0 ? Promise.all(promises) : undefined, ]); } if ( !stateCheckStatus && viewer.loggedIn && viewer.sessionLastValidated + sessionCheckFrequency < Date.now() ) { stateCheckStatus = { status: 'state_check' }; } const serverRequests = []; if (viewerMissingPlatform) { serverRequests.push({ type: serverRequestTypes.PLATFORM }); } if (viewerMissingPlatformDetails) { serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS }); } if (viewerMissingDeviceToken) { serverRequests.push({ type: serverRequestTypes.DEVICE_TOKEN }); } return { serverRequests, stateCheckStatus, activityUpdateResult }; } async function recordThreadInconsistency( viewer: Viewer, response: ThreadInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.THREAD_INCONSISTENCY, }: ThreadInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } async function recordEntryInconsistency( viewer: Viewer, response: EntryInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.ENTRY_INCONSISTENCY, }: EntryInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } type SessionInitializationResult = | {| sessionContinued: false |} | {| sessionContinued: true, deltaEntryInfoResult: DeltaEntryInfosResponse, sessionUpdate: SessionUpdate, |}; async function initializeSession( viewer: Viewer, calendarQuery: CalendarQuery, oldLastUpdate: ?number, ): Promise { if (!viewer.loggedIn) { return { sessionContinued: false }; } let comparisonResult = null; try { comparisonResult = compareNewCalendarQuery(viewer, calendarQuery); } catch (e) { if (e.message !== 'unknown_error') { throw e; } } if (comparisonResult) { const { difference, sessionUpdate, oldCalendarQuery } = comparisonResult; if (oldLastUpdate !== null && oldLastUpdate !== undefined) { sessionUpdate.lastUpdate = oldLastUpdate; } const deltaEntryInfoResult = await fetchEntriesForSession( viewer, difference, oldCalendarQuery, ); return { sessionContinued: true, deltaEntryInfoResult, sessionUpdate }; } else if (oldLastUpdate !== null && oldLastUpdate !== undefined) { await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } else { // We're only able to create the session if we have oldLastUpdate. At this // time the only code in pingResponder that uses viewer.session should be // gated on oldLastUpdate anyways, so we should be okay just returning. return { sessionContinued: false }; } } type StateCheckResult = {| sessionUpdate?: SessionUpdate, checkStateRequest?: CheckStateServerRequest, |}; async function checkState( viewer: Viewer, status: StateCheckStatus, calendarQuery: CalendarQuery, ): Promise { + const { platformDetails } = viewer; + const shouldCheckUserInfos = + (platformDetails && platformDetails.platform === 'web') || + (platformDetails && + platformDetails.codeVersion !== null && + platformDetails.codeVersion !== undefined && + platformDetails.codeVersion > 58); + if (status.status === 'state_validated') { return { sessionUpdate: { lastValidated: Date.now() } }; } else if (status.status === 'state_check') { - const fetchedData = await promiseAll({ + const promises = { threadsResult: fetchThreadInfos(viewer), entriesResult: fetchEntryInfos(viewer, [calendarQuery]), currentUserInfo: fetchCurrentUserInfo(viewer), - }); - const hashesToCheck = { + userInfosResult: undefined, + }; + if (shouldCheckUserInfos) { + promises.userInfosResult = fetchKnownUserInfos(viewer); + } + const fetchedData = await promiseAll(promises); + let hashesToCheck = { threadInfos: hash(fetchedData.threadsResult.threadInfos), entryInfos: hash( serverEntryInfosObject(fetchedData.entriesResult.rawEntryInfos), ), currentUserInfo: hash(fetchedData.currentUserInfo), }; + if (shouldCheckUserInfos) { + hashesToCheck = { + ...hashesToCheck, + userInfos: hash(fetchedData.userInfosResult), + }; + } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, }; return { checkStateRequest }; } const { invalidKeys } = status; let fetchAllThreads = false, fetchAllEntries = false, + fetchAllUserInfos = false, fetchUserInfo = false; const threadIDsToFetch = [], - entryIDsToFetch = []; + entryIDsToFetch = [], + userIDsToFetch = []; for (let key of invalidKeys) { if (key === 'threadInfos') { fetchAllThreads = true; } else if (key === 'entryInfos') { fetchAllEntries = true; + } else if (key === 'userInfos') { + fetchAllUserInfos = true; } else if (key === 'currentUserInfo') { fetchUserInfo = true; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); threadIDsToFetch.push(threadID); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); entryIDsToFetch.push(entryID); + } else if (key.startsWith('userInfo|')) { + const [, userID] = key.split('|'); + userIDsToFetch.push(userID); } } const fetchPromises = {}; if (fetchAllThreads) { fetchPromises.threadsResult = fetchThreadInfos(viewer); } else if (threadIDsToFetch.length > 0) { fetchPromises.threadsResult = fetchThreadInfos( viewer, SQL`t.id IN (${threadIDsToFetch})`, ); } if (fetchAllEntries) { fetchPromises.entriesResult = fetchEntryInfos(viewer, [calendarQuery]); } else if (entryIDsToFetch.length > 0) { fetchPromises.entryInfos = fetchEntryInfosByID(viewer, entryIDsToFetch); } + if (fetchAllUserInfos) { + fetchPromises.userInfos = fetchKnownUserInfos(viewer); + } else if (userIDsToFetch.length > 0) { + fetchPromises.userInfos = fetchUserInfos(userIDsToFetch); + } if (fetchUserInfo) { fetchPromises.currentUserInfo = fetchCurrentUserInfo(viewer); } const fetchedData = await promiseAll(fetchPromises); const hashesToCheck = {}, failUnmentioned = {}, stateChanges = {}; for (let key of invalidKeys) { if (key === 'threadInfos') { // Instead of returning all threadInfos, we want to narrow down and figure // out which threadInfos don't match first const { threadInfos } = fetchedData.threadsResult; for (let threadID in threadInfos) { hashesToCheck[`threadInfo|${threadID}`] = hash(threadInfos[threadID]); } failUnmentioned.threadInfos = true; } else if (key === 'entryInfos') { // Instead of returning all entryInfos, we want to narrow down and figure // out which entryInfos don't match first const { rawEntryInfos } = fetchedData.entriesResult; for (let rawEntryInfo of rawEntryInfos) { const entryInfo = serverEntryInfo(rawEntryInfo); invariant(entryInfo, 'should be set'); const { id: entryID } = entryInfo; invariant(entryID, 'should be set'); hashesToCheck[`entryInfo|${entryID}`] = hash(entryInfo); } failUnmentioned.entryInfos = true; + } else if (key === 'userInfos') { + // Instead of returning all userInfos, we want to narrow down and figure + // out which userInfos don't match first + const { userInfos } = fetchedData; + for (let userID in userInfos) { + hashesToCheck[`userInfo|${userID}`] = hash(userInfos[userID]); + } + failUnmentioned.userInfos = true; } else if (key === 'currentUserInfo') { stateChanges.currentUserInfo = fetchedData.currentUserInfo; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); const { threadInfos } = fetchedData.threadsResult; const threadInfo = threadInfos[threadID]; if (!threadInfo) { if (!stateChanges.deleteThreadIDs) { stateChanges.deleteThreadIDs = []; } stateChanges.deleteThreadIDs.push(threadID); continue; } if (!stateChanges.rawThreadInfos) { stateChanges.rawThreadInfos = []; } stateChanges.rawThreadInfos.push(threadInfo); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); const rawEntryInfos = fetchedData.entriesResult ? fetchedData.entriesResult.rawEntryInfos : fetchedData.entryInfos; const entryInfo = rawEntryInfos.find( candidate => candidate.id === entryID, ); if (!entryInfo) { if (!stateChanges.deleteEntryIDs) { stateChanges.deleteEntryIDs = []; } stateChanges.deleteEntryIDs.push(entryID); continue; } if (!stateChanges.rawEntryInfos) { stateChanges.rawEntryInfos = []; } stateChanges.rawEntryInfos.push(entryInfo); + } else if (key.startsWith('userInfo|')) { + const { userInfos: fetchedUserInfos } = fetchedData; + const [, userID] = key.split('|'); + const userInfo = fetchedUserInfos[userID]; + if (!userInfo || !userInfo.username) { + if (!stateChanges.deleteUserInfoIDs) { + stateChanges.deleteUserInfoIDs = []; + } + stateChanges.deleteUserInfoIDs.push(userID); + continue; + } + if (!stateChanges.userInfos) { + stateChanges.userInfos = []; + } + stateChanges.userInfos.push(userInfo); } } - const userIDs = new Set(); - if (stateChanges.rawThreadInfos) { - for (let threadInfo of stateChanges.rawThreadInfos) { - for (let userID of usersInThreadInfo(threadInfo)) { - userIDs.add(userID); + if (!shouldCheckUserInfos) { + const userIDs = new Set(); + if (stateChanges.rawThreadInfos) { + for (let threadInfo of stateChanges.rawThreadInfos) { + for (let userID of usersInThreadInfo(threadInfo)) { + userIDs.add(userID); + } } } - } - if (stateChanges.rawEntryInfos) { - for (let userID of usersInRawEntryInfos(stateChanges.rawEntryInfos)) { - userIDs.add(userID); + if (stateChanges.rawEntryInfos) { + for (let userID of usersInRawEntryInfos(stateChanges.rawEntryInfos)) { + userIDs.add(userID); + } } - } - const threadUserInfos = fetchedData.threadsResult - ? fetchedData.threadsResult.userInfos - : null; - const entryUserInfos = fetchedData.entriesResult - ? fetchedData.entriesResult.userInfos - : null; - const allUserInfos = { ...threadUserInfos, ...entryUserInfos }; - - const userInfos = []; - const userIDsToFetch = []; - for (let userID of userIDs) { - const userInfo = allUserInfos[userID]; - if (userInfo) { - userInfos.push(userInfo); - } else { - userIDsToFetch.push(userID); + const threadUserInfos = fetchedData.threadsResult + ? fetchedData.threadsResult.userInfos + : null; + const entryUserInfos = fetchedData.entriesResult + ? fetchedData.entriesResult.userInfos + : null; + const allUserInfos = { ...threadUserInfos, ...entryUserInfos }; + + const userInfos = []; + const oldUserIDsToFetch = []; + for (let userID of userIDs) { + const userInfo = allUserInfos[userID]; + if (userInfo) { + userInfos.push(userInfo); + } else { + userIDsToFetch.push(userID); + } } - } - if (userIDsToFetch.length > 0) { - const fetchedUserInfos = await fetchUserInfos(userIDsToFetch); - for (let userID in fetchedUserInfos) { - const userInfo = fetchedUserInfos[userID]; - if (userInfo && userInfo.username) { - const { id, username } = userInfo; - userInfos.push({ id, username }); + if (userIDsToFetch.length > 0) { + const fetchedUserInfos = await fetchUserInfos(oldUserIDsToFetch); + for (let userID in fetchedUserInfos) { + const userInfo = fetchedUserInfos[userID]; + if (userInfo && userInfo.username) { + const { id, username } = userInfo; + userInfos.push({ id, username }); + } } } + if (userInfos.length > 0) { + stateChanges.userInfos = userInfos; + } } - if (userInfos.length > 0) { - stateChanges.userInfos = userInfos; - } - const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, failUnmentioned, stateChanges, }; if (Object.keys(hashesToCheck).length === 0) { return { checkStateRequest, sessionUpdate: { lastValidated: Date.now() } }; } else { return { checkStateRequest }; } } export { clientResponseInputValidator, pingResponder, processClientResponses, initializeSession, checkState, };