diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js index 428f8fbe1..e7113da6a 100644 --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -1,525 +1,525 @@ // @flow import t from 'tcomb'; import type { TType } from 'tcomb'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import type { PolicyType } from 'lib/facts/policies.js'; import type { Endpoint } from 'lib/types/endpoints.js'; +import { calendarQueryValidator } from 'lib/types/entry-types.js'; import { endpointValidators } from 'lib/types/validators/endpoint-validators.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { updateActivityResponder, threadSetUnreadStatusResponder, setThreadUnreadStatusValidator, updateActivityResponderInputValidator, } from './responders/activity-responders.js'; import { fetchCommunityInfosResponder } from './responders/community-responders.js'; import { deviceTokenUpdateResponder, deviceTokenUpdateRequestInputValidator, } from './responders/device-responders.js'; import { entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, createEntryRequestInputValidator, deleteEntryRequestInputValidator, entryQueryInputValidator, entryRevisionHistoryFetchInputValidator, - newEntryQueryInputValidator, restoreEntryRequestInputValidator, saveEntryRequestInputValidator, } from './responders/entry-responders.js'; import { createOrUpdateFarcasterChannelTagResponder, deleteFarcasterChannelTagResponder, createOrUpdateFarcasterChannelTagInputValidator, deleteFarcasterChannelTagInputValidator, } from './responders/farcaster-channel-tag-responders.js'; import type { JSONResponder } from './responders/handlers.js'; import { createJSONResponder } from './responders/handlers.js'; import { getOlmSessionInitializationDataResponder } from './responders/keys-responders.js'; import { createOrUpdatePublicLinkResponder, disableInviteLinkResponder, fetchPrimaryInviteLinksResponder, inviteLinkVerificationResponder, createOrUpdatePublicLinkInputValidator, disableInviteLinkInputValidator, inviteLinkVerificationRequestInputValidator, } from './responders/link-responders.js'; import { messageReportCreationResponder, messageReportCreationRequestInputValidator, } from './responders/message-report-responder.js'; import { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, fetchPinnedMessagesResponder, searchMessagesResponder, sendMultimediaMessageRequestInputValidator, sendReactionMessageRequestInputValidator, editMessageRequestInputValidator, sendTextMessageRequestInputValidator, fetchMessageInfosRequestInputValidator, fetchPinnedMessagesResponderInputValidator, searchMessagesResponderInputValidator, } from './responders/message-responders.js'; import { getInitialReduxStateResponder, initialReduxStateRequestValidator, } from './responders/redux-state-responders.js'; import { updateRelationshipsResponder, updateRelationshipInputValidator, } from './responders/relationship-responders.js'; import { reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, reportCreationRequestInputValidator, fetchErrorReportInfosRequestInputValidator, reportMultiCreationRequestInputValidator, } from './responders/report-responders.js'; import { userSearchResponder, exactUserSearchResponder, exactUserSearchRequestInputValidator, userSearchRequestInputValidator, } from './responders/search-responders.js'; import { siweNonceResponder } from './responders/siwe-nonce-responders.js'; import { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadFetchMediaResponder, threadJoinResponder, toggleMessagePinResponder, roleModificationResponder, roleDeletionResponder, newThreadRequestInputValidator, threadDeletionRequestInputValidator, joinThreadRequestInputValidator, leaveThreadRequestInputValidator, threadFetchMediaRequestInputValidator, removeMembersRequestInputValidator, roleChangeRequestInputValidator, toggleMessagePinRequestInputValidator, updateThreadRequestInputValidator, roleDeletionRequestInputValidator, roleModificationRequestInputValidator, } from './responders/thread-responders.js'; import { keyserverAuthRequestInputValidator, keyserverAuthResponder, userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, registerRequestInputValidator, logInRequestInputValidator, policyAcknowledgmentRequestInputValidator, accountUpdateInputValidator, resetPasswordRequestInputValidator, siweAuthRequestInputValidator, subscriptionUpdateRequestInputValidator, updatePasswordRequestInputValidator, updateUserSettingsInputValidator, claimUsernameResponder, claimUsernameRequestInputValidator, } from './responders/user-responders.js'; import { codeVerificationResponder, codeVerificationRequestInputValidator, } from './responders/verification-responders.js'; import { versionResponder } from './responders/version-responders.js'; import type { Viewer } from './session/viewer.js'; import { uploadMediaMetadataResponder, uploadDeletionResponder, UploadDeletionRequestInputValidator, uploadMediaMetadataInputValidator, } from './uploads/uploads.js'; const ignoredArgumentValidator = t.irreducible( 'Ignored argument', () => true, ); type EndpointData = { responder: (viewer: Viewer, input: any) => Promise<*>, inputValidator: TType<*>, policies: $ReadOnlyArray, }; const jsonEndpointsData: { +[id: Endpoint]: EndpointData } = { create_account: { responder: accountCreationResponder, inputValidator: registerRequestInputValidator, policies: [], }, create_entry: { responder: entryCreationResponder, inputValidator: createEntryRequestInputValidator, policies: baseLegalPolicies, }, create_error_report: { responder: reportCreationResponder, inputValidator: reportCreationRequestInputValidator, policies: [], }, create_message_report: { responder: messageReportCreationResponder, inputValidator: messageReportCreationRequestInputValidator, policies: baseLegalPolicies, }, create_multimedia_message: { responder: multimediaMessageCreationResponder, inputValidator: sendMultimediaMessageRequestInputValidator, policies: baseLegalPolicies, }, create_or_update_public_link: { responder: createOrUpdatePublicLinkResponder, inputValidator: createOrUpdatePublicLinkInputValidator, policies: baseLegalPolicies, }, create_reaction_message: { responder: reactionMessageCreationResponder, inputValidator: sendReactionMessageRequestInputValidator, policies: baseLegalPolicies, }, disable_invite_link: { responder: disableInviteLinkResponder, inputValidator: disableInviteLinkInputValidator, policies: baseLegalPolicies, }, edit_message: { responder: editMessageCreationResponder, inputValidator: editMessageRequestInputValidator, policies: baseLegalPolicies, }, create_report: { responder: reportCreationResponder, inputValidator: reportCreationRequestInputValidator, policies: [], }, create_reports: { responder: reportMultiCreationResponder, inputValidator: reportMultiCreationRequestInputValidator, policies: [], }, create_text_message: { responder: textMessageCreationResponder, inputValidator: sendTextMessageRequestInputValidator, policies: baseLegalPolicies, }, create_thread: { responder: threadCreationResponder, inputValidator: newThreadRequestInputValidator, policies: baseLegalPolicies, }, delete_account: { responder: accountDeletionResponder, inputValidator: ignoredArgumentValidator, policies: [], }, delete_entry: { responder: entryDeletionResponder, inputValidator: deleteEntryRequestInputValidator, policies: baseLegalPolicies, }, delete_community_role: { responder: roleDeletionResponder, inputValidator: roleDeletionRequestInputValidator, policies: baseLegalPolicies, }, delete_thread: { responder: threadDeletionResponder, inputValidator: threadDeletionRequestInputValidator, policies: baseLegalPolicies, }, delete_upload: { responder: uploadDeletionResponder, inputValidator: UploadDeletionRequestInputValidator, policies: baseLegalPolicies, }, exact_search_user: { responder: exactUserSearchResponder, inputValidator: exactUserSearchRequestInputValidator, policies: [], }, fetch_entries: { responder: entryFetchResponder, inputValidator: entryQueryInputValidator, policies: baseLegalPolicies, }, fetch_entry_revisions: { responder: entryRevisionFetchResponder, inputValidator: entryRevisionHistoryFetchInputValidator, policies: baseLegalPolicies, }, fetch_error_report_infos: { responder: errorReportFetchInfosResponder, inputValidator: fetchErrorReportInfosRequestInputValidator, policies: baseLegalPolicies, }, fetch_messages: { responder: messageFetchResponder, inputValidator: fetchMessageInfosRequestInputValidator, policies: baseLegalPolicies, }, fetch_pinned_messages: { responder: fetchPinnedMessagesResponder, inputValidator: fetchPinnedMessagesResponderInputValidator, policies: baseLegalPolicies, }, fetch_primary_invite_links: { responder: fetchPrimaryInviteLinksResponder, inputValidator: ignoredArgumentValidator, policies: baseLegalPolicies, }, fetch_thread_media: { responder: threadFetchMediaResponder, inputValidator: threadFetchMediaRequestInputValidator, policies: baseLegalPolicies, }, get_initial_redux_state: { responder: getInitialReduxStateResponder, inputValidator: initialReduxStateRequestValidator, policies: [], }, join_thread: { responder: threadJoinResponder, inputValidator: joinThreadRequestInputValidator, policies: baseLegalPolicies, }, keyserver_auth: { responder: keyserverAuthResponder, inputValidator: keyserverAuthRequestInputValidator, policies: [], }, leave_thread: { responder: threadLeaveResponder, inputValidator: leaveThreadRequestInputValidator, policies: baseLegalPolicies, }, log_in: { responder: logInResponder, inputValidator: logInRequestInputValidator, policies: [], }, log_out: { responder: logOutResponder, inputValidator: ignoredArgumentValidator, policies: [], }, modify_community_role: { responder: roleModificationResponder, inputValidator: roleModificationRequestInputValidator, policies: baseLegalPolicies, }, policy_acknowledgment: { responder: policyAcknowledgmentResponder, inputValidator: policyAcknowledgmentRequestInputValidator, policies: [], }, remove_members: { responder: memberRemovalResponder, inputValidator: removeMembersRequestInputValidator, policies: baseLegalPolicies, }, restore_entry: { responder: entryRestorationResponder, inputValidator: restoreEntryRequestInputValidator, policies: baseLegalPolicies, }, search_messages: { responder: searchMessagesResponder, inputValidator: searchMessagesResponderInputValidator, policies: baseLegalPolicies, }, search_users: { responder: userSearchResponder, inputValidator: userSearchRequestInputValidator, policies: baseLegalPolicies, }, send_password_reset_email: { responder: sendPasswordResetEmailResponder, inputValidator: resetPasswordRequestInputValidator, policies: [], }, send_verification_email: { responder: sendVerificationEmailResponder, inputValidator: ignoredArgumentValidator, policies: [], }, set_thread_unread_status: { responder: threadSetUnreadStatusResponder, inputValidator: setThreadUnreadStatusValidator, policies: baseLegalPolicies, }, toggle_message_pin: { responder: toggleMessagePinResponder, inputValidator: toggleMessagePinRequestInputValidator, policies: baseLegalPolicies, }, update_account: { responder: passwordUpdateResponder, inputValidator: accountUpdateInputValidator, policies: baseLegalPolicies, }, update_activity: { responder: updateActivityResponder, inputValidator: updateActivityResponderInputValidator, policies: baseLegalPolicies, }, update_calendar_query: { responder: calendarQueryUpdateResponder, - inputValidator: newEntryQueryInputValidator, + inputValidator: calendarQueryValidator, policies: baseLegalPolicies, }, update_user_settings: { responder: updateUserSettingsResponder, inputValidator: updateUserSettingsInputValidator, policies: baseLegalPolicies, }, update_device_token: { responder: deviceTokenUpdateResponder, inputValidator: deviceTokenUpdateRequestInputValidator, policies: [], }, update_entry: { responder: entryUpdateResponder, inputValidator: saveEntryRequestInputValidator, policies: baseLegalPolicies, }, update_password: { responder: oldPasswordUpdateResponder, inputValidator: updatePasswordRequestInputValidator, policies: baseLegalPolicies, }, update_relationships: { responder: updateRelationshipsResponder, inputValidator: updateRelationshipInputValidator, policies: baseLegalPolicies, }, update_role: { responder: roleUpdateResponder, inputValidator: roleChangeRequestInputValidator, policies: baseLegalPolicies, }, update_thread: { responder: threadUpdateResponder, inputValidator: updateThreadRequestInputValidator, policies: baseLegalPolicies, }, update_user_subscription: { responder: userSubscriptionUpdateResponder, inputValidator: subscriptionUpdateRequestInputValidator, policies: baseLegalPolicies, }, verify_code: { responder: codeVerificationResponder, inputValidator: codeVerificationRequestInputValidator, policies: baseLegalPolicies, }, verify_invite_link: { responder: inviteLinkVerificationResponder, inputValidator: inviteLinkVerificationRequestInputValidator, policies: baseLegalPolicies, }, siwe_nonce: { responder: siweNonceResponder, inputValidator: ignoredArgumentValidator, policies: [], }, siwe_auth: { responder: siweAuthResponder, inputValidator: siweAuthRequestInputValidator, policies: [], }, claim_username: { responder: claimUsernameResponder, inputValidator: claimUsernameRequestInputValidator, policies: [], }, update_user_avatar: { responder: updateUserAvatarResponder, inputValidator: updateUserAvatarRequestValidator, policies: baseLegalPolicies, }, upload_media_metadata: { responder: uploadMediaMetadataResponder, inputValidator: uploadMediaMetadataInputValidator, policies: baseLegalPolicies, }, get_olm_session_initialization_data: { responder: getOlmSessionInitializationDataResponder, inputValidator: ignoredArgumentValidator, policies: [], }, version: { responder: versionResponder, inputValidator: ignoredArgumentValidator, policies: [], }, fetch_community_infos: { responder: fetchCommunityInfosResponder, inputValidator: ignoredArgumentValidator, policies: baseLegalPolicies, }, create_or_update_farcaster_channel_tag: { responder: createOrUpdateFarcasterChannelTagResponder, inputValidator: createOrUpdateFarcasterChannelTagInputValidator, policies: baseLegalPolicies, }, delete_farcaster_channel_tag: { responder: deleteFarcasterChannelTagResponder, inputValidator: deleteFarcasterChannelTagInputValidator, policies: baseLegalPolicies, }, }; function createJSONResponders(obj: { +[Endpoint]: EndpointData }): { +[Endpoint]: JSONResponder, } { const result: { [Endpoint]: JSONResponder } = {}; Object.keys(obj).forEach((endpoint: Endpoint) => { const responder = createJSONResponder( obj[endpoint].responder, obj[endpoint].inputValidator, endpointValidators[endpoint].validator, obj[endpoint].policies, ); result[endpoint] = responder; }); return result; } const jsonEndpoints: { +[Endpoint]: JSONResponder } = createJSONResponders(jsonEndpointsData); export { jsonEndpoints }; diff --git a/keyserver/src/responders/entry-responders.js b/keyserver/src/responders/entry-responders.js index 832e1f48a..72569a73b 100644 --- a/keyserver/src/responders/entry-responders.js +++ b/keyserver/src/responders/entry-responders.js @@ -1,257 +1,230 @@ // @flow import t from 'tcomb'; import type { TInterface } from 'tcomb'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { type CalendarQuery, type SaveEntryRequest, type CreateEntryRequest, type DeleteEntryRequest, type DeleteEntryResponse, type RestoreEntryRequest, type RestoreEntryResponse, type FetchEntryInfosResponse, type DeltaEntryInfosResult, type SaveEntryResponse, + calendarQueryValidator, } from 'lib/types/entry-types.js'; import { type CalendarFilter, calendarThreadFilterTypes, + calendarFilterValidator, } from 'lib/types/filter-types.js'; import { type FetchEntryRevisionInfosResult, type FetchEntryRevisionInfosRequest, } from 'lib/types/history-types.js'; import { ServerError } from 'lib/utils/errors.js'; -import { tString, tShape, tDate, tID } from 'lib/utils/validation-utils.js'; +import { tShape, tDate, tID } from 'lib/utils/validation-utils.js'; import createEntry from '../creators/entry-creator.js'; import { deleteEntry, restoreEntry } from '../deleters/entry-deleters.js'; import { fetchEntryInfos, fetchEntryRevisionInfo, fetchEntriesForSession, } from '../fetchers/entry-fetchers.js'; import { verifyThreadIDs } from '../fetchers/thread-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { updateEntry, compareNewCalendarQuery, } from '../updaters/entry-updaters.js'; import { commitSessionUpdate } from '../updaters/session-updaters.js'; type EntryQueryInput = { +startDate: string, +endDate: string, +navID?: ?string, +includeDeleted?: ?boolean, +filters?: ?$ReadOnlyArray, }; const entryQueryInputValidator: TInterface = tShape({ navID: t.maybe(t.String), startDate: tDate, endDate: tDate, includeDeleted: t.maybe(t.Boolean), - filters: t.maybe( - t.list( - t.union([ - tShape({ - type: tString(calendarThreadFilterTypes.NOT_DELETED), - }), - tShape({ - type: tString(calendarThreadFilterTypes.THREAD_LIST), - threadIDs: t.list(tID), - }), - ]), - ), - ), + filters: t.maybe(t.list(calendarFilterValidator)), }); -const newEntryQueryInputValidator: TInterface = tShape({ - startDate: tDate, - endDate: tDate, - filters: t.list( - t.union([ - tShape({ - type: tString(calendarThreadFilterTypes.NOT_DELETED), - }), - tShape({ - type: tString(calendarThreadFilterTypes.THREAD_LIST), - threadIDs: t.list(tID), - }), - ]), - ), -}); - function normalizeCalendarQuery(input: any): CalendarQuery { if (input.filters) { return { startDate: input.startDate, endDate: input.endDate, filters: input.filters, }; } const filters = []; if (!input.includeDeleted) { filters.push({ type: calendarThreadFilterTypes.NOT_DELETED }); } if (input.navID !== 'home') { filters.push({ type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: [input.navID], }); } return { startDate: input.startDate, endDate: input.endDate, filters, }; } async function verifyCalendarQueryThreadIDs( request: CalendarQuery, ): Promise { const threadIDsToFilterTo = filteredThreadIDs(request.filters); if (threadIDsToFilterTo && threadIDsToFilterTo.size > 0) { const verifiedThreadIDs = await verifyThreadIDs([...threadIDsToFilterTo]); if (verifiedThreadIDs.length !== threadIDsToFilterTo.size) { throw new ServerError('invalid_parameters'); } } } async function entryFetchResponder( viewer: Viewer, inputQuery: EntryQueryInput, ): Promise { const request = normalizeCalendarQuery(inputQuery); await verifyCalendarQueryThreadIDs(request); const response = await fetchEntryInfos(viewer, [request]); return { ...response, userInfos: {}, }; } export const entryRevisionHistoryFetchInputValidator: TInterface = tShape({ id: tID, }); async function entryRevisionFetchResponder( viewer: Viewer, request: FetchEntryRevisionInfosRequest, ): Promise { const entryHistory = await fetchEntryRevisionInfo(viewer, request.id); return { result: entryHistory }; } export const createEntryRequestInputValidator: TInterface = tShape({ text: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, date: tDate, threadID: tID, localID: t.maybe(t.String), - calendarQuery: t.maybe(newEntryQueryInputValidator), + calendarQuery: t.maybe(calendarQueryValidator), }); async function entryCreationResponder( viewer: Viewer, request: CreateEntryRequest, ): Promise { return await createEntry(viewer, request); } export const saveEntryRequestInputValidator: TInterface = tShape({ entryID: tID, text: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, - calendarQuery: t.maybe(newEntryQueryInputValidator), + calendarQuery: t.maybe(calendarQueryValidator), }); async function entryUpdateResponder( viewer: Viewer, request: SaveEntryRequest, ): Promise { return await updateEntry(viewer, request); } export const deleteEntryRequestInputValidator: TInterface = tShape({ entryID: tID, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, - calendarQuery: t.maybe(newEntryQueryInputValidator), + calendarQuery: t.maybe(calendarQueryValidator), }); async function entryDeletionResponder( viewer: Viewer, request: DeleteEntryRequest, ): Promise { return await deleteEntry(viewer, request); } export const restoreEntryRequestInputValidator: TInterface = tShape({ entryID: tID, sessionID: t.maybe(t.String), timestamp: t.Number, - calendarQuery: t.maybe(newEntryQueryInputValidator), + calendarQuery: t.maybe(calendarQueryValidator), }); async function entryRestorationResponder( viewer: Viewer, request: RestoreEntryRequest, ): Promise { return await restoreEntry(viewer, request); } async function calendarQueryUpdateResponder( viewer: Viewer, request: CalendarQuery, ): Promise { await verifyCalendarQueryThreadIDs(request); if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { difference, oldCalendarQuery, sessionUpdate } = compareNewCalendarQuery(viewer, request); const [response] = await Promise.all([ fetchEntriesForSession(viewer, difference, oldCalendarQuery), commitSessionUpdate(viewer, sessionUpdate), ]); return { rawEntryInfos: response.rawEntryInfos, deletedEntryIDs: response.deletedEntryIDs, // Old clients expect userInfos object userInfos: [], }; } export { entryQueryInputValidator, - newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, }; diff --git a/keyserver/src/responders/report-responders.js b/keyserver/src/responders/report-responders.js index bffa583e4..2bb510cb0 100644 --- a/keyserver/src/responders/report-responders.js +++ b/keyserver/src/responders/report-responders.js @@ -1,218 +1,218 @@ // @flow import type { $Response, $Request } from 'express'; import t from 'tcomb'; import type { TInterface, TStructProps, TUnion } from 'tcomb'; +import { calendarQueryValidator } from 'lib/types/entry-types.js'; import type { BaseAction } from 'lib/types/redux-types.js'; import { type ReportCreationResponse, type ReportCreationRequest, type FetchErrorReportInfosResponse, type FetchErrorReportInfosRequest, type ThreadInconsistencyReportShape, type EntryInconsistencyReportShape, type ActionSummary, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, type MediaMissionReportCreationRequest, type UserInconsistencyReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tShape, tPlatformDetails } from 'lib/utils/validation-utils.js'; -import { newEntryQueryInputValidator } from './entry-responders.js'; import createReport from '../creators/report-creator.js'; import { fetchErrorReportInfos, fetchReduxToolsImport, } from '../fetchers/report-fetchers.js'; import type { Viewer } from '../session/viewer.js'; const tActionSummary = tShape({ type: t.String, time: t.Number, summary: t.String, }); const tActionType = t.irreducible<$PropertyType>( 'ActionType', x => typeof x === 'string', ); const threadInconsistencyReportValidatorShape: TStructProps = { platformDetails: tPlatformDetails, beforeAction: t.Object, action: t.Object, pollResult: t.maybe(t.Object), pushResult: t.Object, lastActionTypes: t.maybe(t.list(tActionType)), lastActions: t.maybe(t.list(tActionSummary)), time: t.maybe(t.Number), }; const entryInconsistencyReportValidatorShape: TStructProps = { platformDetails: tPlatformDetails, beforeAction: t.Object, action: t.Object, - calendarQuery: newEntryQueryInputValidator, + calendarQuery: calendarQueryValidator, pollResult: t.maybe(t.Object), pushResult: t.Object, lastActionTypes: t.maybe(t.list(tActionType)), lastActions: t.maybe(t.list(tActionSummary)), time: t.Number, }; const userInconsistencyReportValidatorShape = { platformDetails: tPlatformDetails, action: t.Object, beforeStateCheck: t.Object, afterStateCheck: t.Object, lastActions: t.list(tActionSummary), time: t.Number, }; const threadInconsistencyReportCreationRequest = tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'reportTypes.THREAD_INCONSISTENCY', x => x === reportTypes.THREAD_INCONSISTENCY, ), }); const entryInconsistencyReportCreationRquest = tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'reportTypes.ENTRY_INCONSISTENCY', x => x === reportTypes.ENTRY_INCONSISTENCY, ), }); const mediaMissionReportCreationRequest = tShape({ type: t.irreducible( 'reportTypes.MEDIA_MISSION', x => x === reportTypes.MEDIA_MISSION, ), platformDetails: tPlatformDetails, time: t.Number, mediaMission: t.Object, uploadServerID: t.maybe(t.String), uploadLocalID: t.maybe(t.String), mediaLocalID: t.maybe(t.String), messageServerID: t.maybe(t.String), messageLocalID: t.maybe(t.String), }); const userInconsistencyReportCreationRequest = tShape({ ...userInconsistencyReportValidatorShape, type: t.irreducible( 'reportTypes.USER_INCONSISTENCY', x => x === reportTypes.USER_INCONSISTENCY, ), }); export const reportCreationRequestInputValidator: TUnion = t.union([ tShape({ type: t.irreducible( 'reportTypes.ERROR', x => x === reportTypes.ERROR, ), platformDetails: tPlatformDetails, errors: t.list( tShape({ errorMessage: t.String, stack: t.maybe(t.String), componentStack: t.maybe(t.String), }), ), preloadedState: t.Object, currentState: t.Object, actions: t.list(t.Object), }), threadInconsistencyReportCreationRequest, entryInconsistencyReportCreationRquest, mediaMissionReportCreationRequest, userInconsistencyReportCreationRequest, ]); async function reportCreationResponder( viewer: Viewer, request: ReportCreationRequest, ): Promise { if (request.type === null || request.type === undefined) { request.type = reportTypes.ERROR; } if (!request.platformDetails && request.deviceType) { const { deviceType, codeVersion, stateVersion, ...rest } = request; request = { ...rest, platformDetails: { platform: deviceType, codeVersion, stateVersion }, }; } const response = await createReport(viewer, request); if (!response) { throw new ServerError('ignored_report'); } return response; } export const reportMultiCreationRequestInputValidator: TInterface = tShape({ reports: t.list(reportCreationRequestInputValidator), }); type ReportMultiCreationRequest = { +reports: $ReadOnlyArray, }; async function reportMultiCreationResponder( viewer: Viewer, request: ReportMultiCreationRequest, ): Promise { await Promise.all( request.reports.map(reportCreationRequest => createReport(viewer, reportCreationRequest), ), ); } export const fetchErrorReportInfosRequestInputValidator: TInterface = tShape({ cursor: t.maybe(t.String), }); async function errorReportFetchInfosResponder( viewer: Viewer, request: FetchErrorReportInfosRequest, ): Promise { return await fetchErrorReportInfos(viewer, request); } async function errorReportDownloadResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const id = req.params.reportID; if (!id) { throw new ServerError('invalid_parameters'); } const result = await fetchReduxToolsImport(viewer, id); res.set('Content-Disposition', `attachment; filename=report-${id}.json`); res.json({ preloadedState: JSON.stringify(result.preloadedState), payload: JSON.stringify(result.payload), }); } export { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, errorReportDownloadResponder, }; diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index 6b7855879..337d3253d 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,1020 +1,1020 @@ // @flow import type { Utility as OlmUtility } from '@commapp/olm'; import invariant from 'invariant'; import { getRustAPI } from 'rust-node-addon'; import { SiweErrorType, SiweMessage } from 'siwe'; import t, { type TInterface } from 'tcomb'; import bcrypt from 'twin-bcrypt'; import { baseLegalPolicies, policies, policyTypes, type PolicyType, } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { KeyserverAuthRequest, ResetPasswordRequest, LogOutResponse, RegisterResponse, RegisterRequest, ServerLogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, ClaimUsernameRequest, ClaimUsernameResponse, } from 'lib/types/account-types.js'; import { userSettingsTypes, notificationTypeValues, authActionSources, } from 'lib/types/account-types.js'; import { type ClientAvatar, type UpdateUserAvatarResponse, type UpdateUserAvatarRequest, } from 'lib/types/avatar-types.js'; import type { ReservedUsernameMessage, IdentityKeysBlob, SignedIdentityKeysBlob, } from 'lib/types/crypto-types.js'; import type { DeviceType, DeviceTokenUpdateRequest, PlatformDetails, } from 'lib/types/device-types'; import { type CalendarQuery, type FetchEntryInfosBase, + calendarQueryValidator, } from 'lib/types/entry-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import type { SIWEAuthRequest, SIWEMessage, SIWESocialProof, } from 'lib/types/siwe-types.js'; import { type SubscriptionUpdateRequest, type SubscriptionUpdateResponse, } from 'lib/types/subscription-types.js'; import { type PasswordUpdate } from 'lib/types/user-types.js'; import { identityKeysBlobValidator, signedIdentityKeysBlobValidator, } from 'lib/utils/crypto-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { ignorePromiseRejections } from 'lib/utils/promises.js'; import { getPublicKeyFromSIWEStatement, isValidSIWEMessage, isValidSIWEStatementWithPublicKey, primaryIdentityPublicKeyRegex, } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, tRegex, tID, tUserID, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, - newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { createAndSendReservedUsernameMessage, sendMessagesOnAccountCreation, createAccount, } from '../creators/account-creator.js'; import createIDs from '../creators/id-creator.js'; import { createOlmSession, persistFreshOlmSession, } from '../creators/olm-session-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteAccount } from '../deleters/account-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, fetchUserIDForEthereumAddress, fetchUsername, } from '../fetchers/user-fetchers.js'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { passwordUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, updateUserAvatar, } from '../updaters/account-updaters.js'; import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { verifyUserLoggedIn } from '../user/login.js'; import { getOlmUtility, getContentSigningKey } from '../utils/olm-utils.js'; export const subscriptionUpdateRequestInputValidator: TInterface = tShape({ threadID: tID, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, request: SubscriptionUpdateRequest, ): Promise { const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription, }; } export const accountUpdateInputValidator: TInterface = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, request: PasswordUpdate, ): Promise { await passwordUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await checkAndSendVerificationEmail(viewer); } export const resetPasswordRequestInputValidator: TInterface = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, request: ResetPasswordRequest, ): Promise { await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { anonymous: true, }, }; } async function accountDeletionResponder( viewer: Viewer, ): Promise { const result = await deleteAccount(viewer); invariant(result, 'deleteAccount should return result if handed request'); return result; } type OldDeviceTokenUpdateRequest = { +deviceType?: ?DeviceType, +deviceToken: string, }; const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); export const registerRequestInputValidator: TInterface = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, - calendarQuery: t.maybe(newEntryQueryInputValidator), + calendarQuery: t.maybe(calendarQueryValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), initialNotificationsEncryptedMessage: t.maybe(t.String), }); async function accountCreationResponder( viewer: Viewer, request: RegisterRequest, ): Promise { const { signedIdentityKeysBlob } = request; if (signedIdentityKeysBlob) { const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } return await createAccount(viewer, request); } type ProcessSuccessfulLoginParams = { +viewer: Viewer, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +userID: string, +calendarQuery: ?CalendarQuery, +socialProof?: ?SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, +initialNotificationsEncryptedMessage?: string, +pickledContentOlmSession?: string, +shouldMarkPoliciesAsAcceptedAfterCookieCreation?: boolean, }; type ProcessSuccessfulLoginResult = | { +success: true, +newServerTime: number, } | { +success: false, +notAcknowledgedPolicies: $ReadOnlyArray, }; async function processSuccessfulLogin( params: ProcessSuccessfulLoginParams, ): Promise { const { viewer, deviceTokenUpdateRequest, platformDetails, userID, calendarQuery, socialProof, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, pickledContentOlmSession, shouldMarkPoliciesAsAcceptedAfterCookieCreation, } = params; // Olm sessions have to be created before createNewUserCookie is called, // to avoid propagating a user cookie in case session creation fails const olmNotifSession = await (async () => { if (initialNotificationsEncryptedMessage && signedIdentityKeysBlob) { return await createOlmSession( initialNotificationsEncryptedMessage, 'notifications', ); } return null; })(); const newServerTime = Date.now(); const deviceToken = deviceTokenUpdateRequest ? deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const setNewCookiePromise = (async () => { const [userViewerData] = await Promise.all([ createNewUserCookie(userID, { platformDetails, deviceToken, socialProof, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); })(); const policiesCheckAndUpdate = (async () => { if (shouldMarkPoliciesAsAcceptedAfterCookieCreation) { await setNewCookiePromise; await viewerAcknowledgmentUpdater( viewer, policyTypes.tosAndPrivacyPolicy, ); } return await fetchNotAcknowledgedPolicies(userID, baseLegalPolicies); })(); const [notAcknowledgedPolicies] = await Promise.all([ policiesCheckAndUpdate, setNewCookiePromise, ]); if ( notAcknowledgedPolicies.length && hasMinCodeVersion(viewer.platformDetails, { native: 181 }) ) { return { success: false, notAcknowledgedPolicies }; } if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const persistOlmNotifSessionPromise = (async () => { if (olmNotifSession && viewer.cookieID) { await persistFreshOlmSession( olmNotifSession, 'notifications', viewer.cookieID, ); } })(); // `pickledContentOlmSession` is created in `keyserverAuthResponder(...)` in // order to authenticate the user. Here, we simply persist the session if it // exists. const persistOlmContentSessionPromise = (async () => { if (viewer.cookieID && pickledContentOlmSession) { await persistFreshOlmSession( pickledContentOlmSession, 'content', viewer.cookieID, ); } })(); await Promise.all([ persistOlmNotifSessionPromise, persistOlmContentSessionPromise, ]); return { success: true, newServerTime }; } type FetchLoginResponseParams = { +viewer: Viewer, +watchedIDs: $ReadOnlyArray, +calendarQuery: ?CalendarQuery, +newServerTime: number, }; async function fetchLoginResponse( params: FetchLoginResponseParams, ): Promise { const { viewer, watchedIDs, calendarQuery } = params; const threadCursors: { [string]: null } = {}; for (const watchedThreadID of watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const entriesPromise: Promise = (async () => { if (!calendarQuery) { return undefined; } return await fetchEntryInfos(viewer, [calendarQuery]); })(); const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), entriesPromise, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: ServerLogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: params.newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { return { ...response, rawEntryInfos, }; } return response; } type HandleSuccessfulLoginResultParams = { +viewer: Viewer, +watchedIDs: $ReadOnlyArray, +calendarQuery: ?CalendarQuery, }; async function handleSuccessfulLoginResult( result: ProcessSuccessfulLoginResult, params: HandleSuccessfulLoginResultParams, ): Promise { const { viewer, watchedIDs, calendarQuery } = params; if (!result.success) { const currentUserInfo = await fetchLoggedInUserInfo(viewer); return { notAcknowledgedPolicies: result.notAcknowledgedPolicies, currentUserInfo: currentUserInfo, rawMessageInfos: [], truncationStatuses: {}, userInfos: [], rawEntryInfos: [], serverTime: 0, cookieChange: { threadInfos: {}, userInfos: [], }, }; } return await fetchLoginResponse({ viewer, watchedIDs, calendarQuery, newServerTime: result.newServerTime, }); } export const logInRequestInputValidator: TInterface = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(authActionSources))), // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), initialNotificationsEncryptedMessage: t.maybe(t.String), }); async function logInResponder( viewer: Viewer, request: LogInRequest, ): Promise { let identityKeys: ?IdentityKeysBlob; const { signedIdentityKeysBlob, initialNotificationsEncryptedMessage } = request; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const verifyCalendarQueryThreadIDsPromise = (async () => { if (calendarQuery) { await verifyCalendarQueryThreadIDs(calendarQuery); } })(); const username = request.username ?? request.usernameOrEmail; if (!username) { if (hasMinCodeVersion(viewer.platformDetails, { native: 150 })) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; const userQueryPromise = dbQuery(userQuery); const [[userResult]] = await Promise.all([ userQueryPromise, verifyCalendarQueryThreadIDsPromise, ]); if (userResult.length === 0) { if (hasMinCodeVersion(viewer.platformDetails, { native: 150 })) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); const processSuccessfulLoginResult = await processSuccessfulLogin({ viewer, platformDetails: request.platformDetails, deviceTokenUpdateRequest: request.deviceTokenUpdateRequest, userID: id, calendarQuery, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, }); return await handleSuccessfulLoginResult(processSuccessfulLoginResult, { viewer, watchedIDs: request.watchedIDs, calendarQuery, }); } export const siweAuthRequestInputValidator: TInterface = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(tID), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), initialNotificationsEncryptedMessage: t.maybe(t.String), doNotRegister: t.maybe(t.Boolean), }); async function siweAuthResponder( viewer: Viewer, request: SIWEAuthRequest, ): Promise { const { message, signature, deviceTokenUpdateRequest, platformDetails, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, doNotRegister, watchedIDs, } = request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Ensure that `message` is a well formed Comm SIWE Auth message. const siweMessage: SIWEMessage = new SiweMessage(message); if (!isValidSIWEMessage(siweMessage)) { throw new ServerError('invalid_parameters'); } // 2. Check if there's already a user for this ETH address. // Verify calendarQuery. const [existingUserID] = await Promise.all([ fetchUserIDForEthereumAddress(siweMessage.address), verifyCalendarQueryThreadIDs(calendarQuery), ]); if (!existingUserID && doNotRegister) { throw new ServerError('account_does_not_exist'); } // 3. Ensure that the `nonce` exists in the `siwe_nonces` table // AND hasn't expired. If those conditions are met, delete the entry to // ensure that the same `nonce` can't be re-used in a future request. const wasNonceCheckedAndInvalidated = await checkAndInvalidateSIWENonceEntry( siweMessage.nonce, ); if (!wasNonceCheckedAndInvalidated) { throw new ServerError('invalid_parameters'); } // 4. Validate SIWEMessage signature and handle possible errors. try { await siweMessage.verify({ signature }); } catch (error) { if (error === SiweErrorType.EXPIRED_MESSAGE) { // Thrown when the `expirationTime` is present and in the past. throw new ServerError('expired_message'); } else if (error === SiweErrorType.INVALID_SIGNATURE) { // Thrown when the `validate()` function can't verify the message. throw new ServerError('invalid_signature'); } else { throw new ServerError('unknown_error'); } } // 5. Pull `primaryIdentityPublicKey` out from SIWEMessage `statement`. // We expect it to be included for BOTH native and web clients. const { statement } = siweMessage; const primaryIdentityPublicKey = statement && isValidSIWEStatementWithPublicKey(statement) ? getPublicKeyFromSIWEStatement(statement) : null; if (!primaryIdentityPublicKey) { throw new ServerError('invalid_siwe_statement_public_key'); } // 6. Verify `signedIdentityKeysBlob.payload` with included `signature` // if `signedIdentityKeysBlob` was included in the `SIWEAuthRequest`. let identityKeys: ?IdentityKeysBlob; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } // 7. Ensure that `primaryIdentityPublicKeys.ed25519` matches SIWE // statement `primaryIdentityPublicKey` if `identityKeys` exists. if ( identityKeys && identityKeys.primaryIdentityPublicKeys.ed25519 !== primaryIdentityPublicKey ) { throw new ServerError('primary_public_key_mismatch'); } // 8. Construct `SIWESocialProof` object with the stringified // SIWEMessage and the corresponding signature. const socialProof: SIWESocialProof = { siweMessage: siweMessage.toMessage(), siweMessageSignature: signature, }; // 9. Create account if address does not correspond to an existing user. const userID = await (async () => { if (existingUserID) { return existingUserID; } const time = Date.now(); const [id] = await createIDs('users', 1); const newUserRow = [id, siweMessage.address, siweMessage.address, time]; const newUserQuery = SQL` INSERT INTO users(id, username, ethereum_address, creation_time) VALUES ${[newUserRow]} `; await dbQuery(newUserQuery); return id; })(); // 10. Complete login with call to `processSuccessfulLogin(...)`. const processSuccessfulLoginResult = await processSuccessfulLogin({ viewer, platformDetails, deviceTokenUpdateRequest, userID, calendarQuery, socialProof, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, shouldMarkPoliciesAsAcceptedAfterCookieCreation: !existingUserID, }); // 11. Create messages with call to `sendMessagesOnAccountCreation(...)`, // if the account has just been registered. Also, set the username as // reserved. if (!existingUserID) { await sendMessagesOnAccountCreation(viewer); ignorePromiseRejections( createAndSendReservedUsernameMessage([ { username: siweMessage.address, userID }, ]), ); } // 12. Fetch data from MariaDB for the response. return await handleSuccessfulLoginResult(processSuccessfulLoginResult, { viewer, watchedIDs, calendarQuery, }); } export const keyserverAuthRequestInputValidator: TInterface = tShape({ userID: tUserID, deviceID: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(tID), initialContentEncryptedMessage: t.String, initialNotificationsEncryptedMessage: t.String, doNotRegister: t.Boolean, source: t.maybe(t.enums.of(values(authActionSources))), }); async function keyserverAuthResponder( viewer: Viewer, request: KeyserverAuthRequest, ): Promise { const { userID, deviceID, initialContentEncryptedMessage, initialNotificationsEncryptedMessage, doNotRegister, } = request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Check if there's already a user for this userID. Simultaneously, get // info for identity service auth. const [existingUsername, authDeviceID, identityInfo, rustAPI] = await Promise.all([ fetchUsername(userID), getContentSigningKey(), verifyUserLoggedIn(), getRustAPI(), verifyCalendarQueryThreadIDs(calendarQuery), ]); if (!existingUsername && doNotRegister) { throw new ServerError('account_does_not_exist'); } if (!identityInfo) { throw new ServerError('account_not_registered_on_identity_service'); } // 2. Get user's keys from identity service. let inboundKeysForUser; try { inboundKeysForUser = await rustAPI.getInboundKeysForUserDevice( identityInfo.userId, authDeviceID, identityInfo.accessToken, userID, deviceID, ); } catch (e) { console.log(e); throw new ServerError('failed_to_retrieve_inbound_keys'); } const username = inboundKeysForUser.username ? inboundKeysForUser.username : inboundKeysForUser.walletAddress; if (!username) { throw new ServerError('user_identifier_missing'); } const identityKeys: IdentityKeysBlob = JSON.parse(inboundKeysForUser.payload); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } // 3. Create content olm session. (The notif session was introduced first and // as such is created in legacy auth responders as well. It's factored out // into in the shared utility `processSuccessfulLogin(...)`.) const pickledContentOlmSessionPromise = createOlmSession( initialContentEncryptedMessage, 'content', identityKeys.primaryIdentityPublicKeys.curve25519, ); // 4. Create account if username does not correspond to an existing user. const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: inboundKeysForUser.payload, signature: inboundKeysForUser.payloadSignature, }; const olmAccountCreationPromise = (async () => { if (existingUsername) { return; } const time = Date.now(); const newUserRow = [ userID, username, inboundKeysForUser.walletAddress, time, ]; const newUserQuery = SQL` INSERT INTO users(id, username, ethereum_address, creation_time) VALUES ${[newUserRow]} `; await dbQuery(newUserQuery); })(); const [pickledContentOlmSession] = await Promise.all([ pickledContentOlmSessionPromise, olmAccountCreationPromise, ]); // 5. Complete login with call to `processSuccessfulLogin(...)`. const processSuccessfulLoginResult = await processSuccessfulLogin({ viewer, platformDetails: request.platformDetails, deviceTokenUpdateRequest: request.deviceTokenUpdateRequest, userID, calendarQuery, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, pickledContentOlmSession, shouldMarkPoliciesAsAcceptedAfterCookieCreation: !existingUsername, }); // 6. Create messages with call to `sendMessagesOnAccountCreation(...)`, // if the account has just been registered. if (!existingUsername) { await sendMessagesOnAccountCreation(viewer); } // 7. Fetch data from MariaDB for the response. return await handleSuccessfulLoginResult(processSuccessfulLoginResult, { viewer, watchedIDs: request.watchedIDs, calendarQuery, }); } export const updatePasswordRequestInputValidator: TInterface = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } export const updateUserSettingsInputValidator: TInterface = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, request: UpdateUserSettingsRequest, ): Promise { await updateUserSettings(viewer, request); } export const policyAcknowledgmentRequestInputValidator: TInterface = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, request: PolicyAcknowledgmentRequest, ): Promise { await viewerAcknowledgmentUpdater(viewer, request.policy); } async function updateUserAvatarResponder( viewer: Viewer, request: UpdateUserAvatarRequest, ): Promise { return await updateUserAvatar(viewer, request); } export const claimUsernameRequestInputValidator: TInterface = tShape({ username: t.String, password: tPassword, }); async function claimUsernameResponder( viewer: Viewer, request: ClaimUsernameRequest, ): Promise { const username = request.username; const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${request.username}) `; const [[userResult], accountInfo] = await Promise.all([ dbQuery(userQuery), fetchOlmAccount('content'), ]); if (userResult.length === 0) { throw new ServerError('invalid_credentials'); } const userRow = userResult[0]; if (!userRow.hash) { throw new ServerError('invalid_parameters'); } if (!bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const userID = userRow.id; const issuedAt = new Date().toISOString(); const reservedUsernameMessage: ReservedUsernameMessage = { statement: 'This user is the owner of the following username and user ID', payload: { username, userID, }, issuedAt, }; const message = JSON.stringify(reservedUsernameMessage); const signature = accountInfo.account.sign(message); return { message, signature }; } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, claimUsernameResponder, keyserverAuthResponder, }; diff --git a/keyserver/src/socket/socket.js b/keyserver/src/socket/socket.js index 0d49c399b..5f663e84d 100644 --- a/keyserver/src/socket/socket.js +++ b/keyserver/src/socket/socket.js @@ -1,891 +1,891 @@ // @flow import type { $Request } from 'express'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import t from 'tcomb'; import type { TUnion } from 'tcomb'; import WebSocket from 'ws'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import { serverRequestSocketTimeout, serverResponseTimeout, } from 'lib/shared/timeouts.js'; import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { endpointIsSocketSafe } from 'lib/types/endpoints.js'; -import type { RawEntryInfo } from 'lib/types/entry-types.js'; +import { + type RawEntryInfo, + calendarQueryValidator, +} from 'lib/types/entry-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { redisMessageTypes, type RedisMessage } from 'lib/types/redis-types.js'; import { serverRequestTypes } from 'lib/types/request-types.js'; import { sessionCheckFrequency, stateCheckInactivityActivationInterval, } from 'lib/types/session-types.js'; import { type ClientSocketMessage, type InitialClientSocketMessage, type ResponsesClientSocketMessage, type ServerStateSyncFullSocketPayload, type ServerServerSocketMessage, type ErrorServerSocketMessage, type AuthErrorServerSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, clientSocketMessageTypes, stateSyncPayloadTypes, serverSocketMessageTypes, serverServerSocketMessageValidator, } from 'lib/types/socket-types.js'; import type { LegacyRawThreadInfos } from 'lib/types/thread-types.js'; import type { UserInfo, CurrentUserInfo } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll, ignorePromiseRejections } from 'lib/utils/promises.js'; import SequentialPromiseResolver from 'lib/utils/sequential-promise-resolver.js'; import sleep from 'lib/utils/sleep.js'; import { tShape, tCookie } from 'lib/utils/validation-utils.js'; import { RedisSubscriber } from './redis.js'; import { clientResponseInputValidator, processClientResponses, initializeSession, checkState, } from './session-utils.js'; import { fetchUpdateInfosWithRawUpdateInfos } from '../creators/update-creator.js'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { deleteUpdatesBeforeTimeTargetingSession } from '../deleters/update-deleters.js'; import { jsonEndpoints } from '../endpoints.js'; import { fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, } from '../fetchers/message-fetchers.js'; import { fetchUpdateInfos } from '../fetchers/update-fetchers.js'; -import { - newEntryQueryInputValidator, - verifyCalendarQueryThreadIDs, -} from '../responders/entry-responders.js'; +import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders.js'; import { fetchViewerForSocket, updateCookie, isCookieMissingSignedIdentityKeysBlob, isCookieMissingOlmNotificationsSession, createNewAnonymousCookie, } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; import type { AnonymousViewerData } from '../session/viewer.js'; import { serverStateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { commitSessionUpdate } from '../updaters/session-updaters.js'; import { compressMessage } from '../utils/compress.js'; import { assertSecureRequest } from '../utils/security-utils.js'; import { checkInputValidator, checkClientSupported, policiesValidator, validateOutput, validateInput, } from '../utils/validation-utils.js'; const clientSocketMessageInputValidator: TUnion = t.union([ tShape({ type: t.irreducible( 'clientSocketMessageTypes.INITIAL', x => x === clientSocketMessageTypes.INITIAL, ), id: t.Number, payload: tShape({ sessionIdentification: tShape({ cookie: t.maybe(tCookie), sessionID: t.maybe(t.String), }), sessionState: tShape({ - calendarQuery: newEntryQueryInputValidator, + calendarQuery: calendarQueryValidator, messagesCurrentAsOf: t.Number, updatesCurrentAsOf: t.Number, watchedIDs: t.list(t.String), }), clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.RESPONSES', x => x === clientSocketMessageTypes.RESPONSES, ), id: t.Number, payload: tShape({ clientResponses: t.list(clientResponseInputValidator), }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.PING', x => x === clientSocketMessageTypes.PING, ), id: t.Number, }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.ACK_UPDATES', x => x === clientSocketMessageTypes.ACK_UPDATES, ), id: t.Number, payload: tShape({ currentAsOf: t.Number, }), }), tShape({ type: t.irreducible( 'clientSocketMessageTypes.API_REQUEST', x => x === clientSocketMessageTypes.API_REQUEST, ), id: t.Number, payload: tShape({ endpoint: t.String, input: t.maybe(t.Object), }), }), ]); function onConnection(ws: WebSocket, req: $Request) { assertSecureRequest(req); new Socket(ws, req); } type StateCheckConditions = { activityRecentlyOccurred: boolean, stateCheckOngoing: boolean, }; const minVersionsForCompression = { native: 265, web: 30, }; class Socket { ws: WebSocket; httpRequest: $Request; viewer: ?Viewer; redis: ?RedisSubscriber; redisPromiseResolver: SequentialPromiseResolver; stateCheckConditions: StateCheckConditions = { activityRecentlyOccurred: true, stateCheckOngoing: false, }; stateCheckTimeoutID: ?TimeoutID; constructor(ws: WebSocket, httpRequest: $Request) { this.ws = ws; this.httpRequest = httpRequest; ws.on('message', this.onMessage); ws.on('close', this.onClose); this.resetTimeout(); this.redisPromiseResolver = new SequentialPromiseResolver(this.sendMessage); } onMessage = async ( messageString: string | Buffer | ArrayBuffer | Array, ): Promise => { invariant(typeof messageString === 'string', 'message should be string'); let responseTo = null; try { this.resetTimeout(); const messageObject = JSON.parse(messageString); const clientSocketMessageWithClientIDs = checkInputValidator( clientSocketMessageInputValidator, messageObject, ); responseTo = clientSocketMessageWithClientIDs.id; if ( clientSocketMessageWithClientIDs.type === clientSocketMessageTypes.INITIAL ) { if (this.viewer) { // This indicates that the user sent multiple INITIAL messages. throw new ServerError('socket_already_initialized'); } this.viewer = await fetchViewerForSocket( this.httpRequest, clientSocketMessageWithClientIDs, ); } const { viewer } = this; if (!viewer) { // This indicates a non-INITIAL message was sent by the client before // the INITIAL message. throw new ServerError('socket_uninitialized'); } if (viewer.sessionChanged) { // This indicates that the cookie was invalid, and we've assigned a new // anonymous one. throw new ServerError('socket_deauthorized'); } if (!viewer.loggedIn) { // This indicates that the specified cookie was an anonymous one. throw new ServerError('not_logged_in'); } await checkClientSupported( viewer, clientSocketMessageInputValidator, clientSocketMessageWithClientIDs, ); await policiesValidator(viewer, baseLegalPolicies); const clientSocketMessage = await validateInput( viewer, clientSocketMessageInputValidator, clientSocketMessageWithClientIDs, ); const serverResponses = await this.handleClientSocketMessage(clientSocketMessage); if (!this.redis) { this.redis = new RedisSubscriber( { userID: viewer.userID, sessionID: viewer.session }, this.onRedisMessage, ); } if (viewer.sessionChanged) { // This indicates that something has caused the session to change, which // shouldn't happen from inside a WebSocket since we can't handle cookie // invalidation. throw new ServerError('session_mutated_from_socket'); } if (clientSocketMessage.type !== clientSocketMessageTypes.PING) { ignorePromiseRejections(updateCookie(viewer)); } for (const response of serverResponses) { // Normally it's an anti-pattern to await in sequence like this. But in // this case, we have a requirement that this array of serverResponses // is delivered in order. See here: // https://github.com/CommE2E/comm/blob/101eb34481deb49c609bfd2c785f375886e52666/keyserver/src/socket/socket.js#L566-L568 await this.sendMessage(response); } if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) { this.onSuccessfulConnection(); } } catch (error) { console.warn(error); if (!(error instanceof ServerError)) { const errorMessage: ErrorServerSocketMessage = { type: serverSocketMessageTypes.ERROR, message: error.message, }; if (responseTo !== null) { errorMessage.responseTo = responseTo; } this.markActivityOccurred(); await this.sendMessage(errorMessage); return; } invariant(responseTo, 'should be set'); if (error.message === 'socket_deauthorized') { invariant(this.viewer, 'should be set'); const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, sessionChange: { cookie: this.viewer.cookiePairString, currentUserInfo: { anonymous: true, }, }, }; await this.sendMessage(authErrorMessage); this.ws.close(4100, error.message); return; } else if (error.message === 'client_version_unsupported') { const { viewer } = this; invariant(viewer, 'should be set'); const anonymousViewerDataPromise: Promise = createNewAnonymousCookie({ platformDetails: error.platformDetails, deviceToken: viewer.deviceToken, }); const deleteCookiePromise = deleteCookie(viewer.cookieID); const [anonymousViewerData] = await Promise.all([ anonymousViewerDataPromise, deleteCookiePromise, ]); // It is normally not safe to pass the result of // createNewAnonymousCookie to the Viewer constructor. That is because // createNewAnonymousCookie leaves several fields of // AnonymousViewerData unset, and consequently Viewer will throw when // access is attempted. It is only safe here because we can guarantee // that only cookiePairString and cookieID are accessed on anonViewer // below. const anonViewer = new Viewer(anonymousViewerData); const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, sessionChange: { cookie: anonViewer.cookiePairString, currentUserInfo: { anonymous: true, }, }, }; await this.sendMessage(authErrorMessage); this.ws.close(4101, error.message); return; } if (error.payload) { await this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, payload: error.payload, }); } else { await this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, }); } if (error.message === 'not_logged_in') { this.ws.close(4102, error.message); } else if (error.message === 'session_mutated_from_socket') { this.ws.close(4103, error.message); } else { this.markActivityOccurred(); } } }; onClose = async () => { this.clearStateCheckTimeout(); this.resetTimeout.cancel(); this.debouncedAfterActivity.cancel(); if (this.viewer && this.viewer.hasSessionInfo) { await deleteActivityForViewerSession(this.viewer); } if (this.redis) { this.redis.quit(); this.redis = null; } }; sendMessage = async (message: ServerServerSocketMessage) => { invariant( this.ws.readyState > 0, "shouldn't send message until connection established", ); if (this.ws.readyState !== 1) { return; } const { viewer } = this; const validatedMessage = await validateOutput( viewer?.platformDetails, serverServerSocketMessageValidator, message, ); const stringMessage = JSON.stringify(validatedMessage); if ( !viewer?.platformDetails || !hasMinCodeVersion(viewer.platformDetails, minVersionsForCompression) || !isStaff(viewer.id) ) { this.ws.send(stringMessage); return; } const compressionResult = await compressMessage(stringMessage); if (this.ws.readyState !== 1) { return; } if (!compressionResult.compressed) { this.ws.send(stringMessage); return; } const compressedMessage = { type: serverSocketMessageTypes.COMPRESSED_MESSAGE, payload: compressionResult.result, }; const validatedCompressedMessage = await validateOutput( viewer?.platformDetails, serverServerSocketMessageValidator, compressedMessage, ); const stringCompressedMessage = JSON.stringify(validatedCompressedMessage); this.ws.send(stringCompressedMessage); }; async handleClientSocketMessage( message: ClientSocketMessage, ): Promise { const resultPromise: Promise = (async () => { if (message.type === clientSocketMessageTypes.INITIAL) { this.markActivityOccurred(); return await this.handleInitialClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.markActivityOccurred(); return await this.handleResponsesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.PING) { return this.handlePingClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.markActivityOccurred(); return await this.handleAckUpdatesClientSocketMessage(message); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.markActivityOccurred(); return await this.handleAPIRequestClientSocketMessage(message); } return []; })(); const timeoutPromise: Promise = (async () => { await sleep(serverResponseTimeout); throw new ServerError('socket_response_timeout'); })(); return await Promise.race([resultPromise, timeoutPromise]); } async handleInitialClientSocketMessage( message: InitialClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const responses: Array = []; const { sessionState, clientResponses } = message.payload; const { calendarQuery, updatesCurrentAsOf: oldUpdatesCurrentAsOf, messagesCurrentAsOf: oldMessagesCurrentAsOf, watchedIDs, } = sessionState; await verifyCalendarQueryThreadIDs(calendarQuery); const sessionInitializationResult = await initializeSession( viewer, calendarQuery, oldUpdatesCurrentAsOf, ); const threadCursors: { [string]: null } = {}; for (const watchedThreadID of watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true, newerThan: oldMessagesCurrentAsOf, }; const [fetchMessagesResult, { serverRequests, activityUpdateResult }] = await Promise.all([ fetchMessageInfosSince( viewer, messageSelectionCriteria, defaultNumberPerThread, ), processClientResponses(viewer, clientResponses), ]); const messagesResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( fetchMessagesResult.rawMessageInfos, oldMessagesCurrentAsOf, ), }; const isCookieMissingSignedIdentityKeysBlobPromise = isCookieMissingSignedIdentityKeysBlob(viewer.cookieID); const isCookieMissingOlmNotificationsSessionPromise = isCookieMissingOlmNotificationsSession(viewer); if (!sessionInitializationResult.sessionContinued) { const promises: { +[string]: Promise } = Object.fromEntries( values(serverStateSyncSpecs).map(spec => [ spec.hashKey, spec.fetchFullSocketSyncPayload(viewer, [calendarQuery]), ]), ); // We have a type error here because Flow doesn't know spec.hashKey const castPromises: { +threadInfos: Promise, +currentUserInfo: Promise, +entryInfos: Promise<$ReadOnlyArray>, +userInfos: Promise<$ReadOnlyArray>, } = (promises: any); const results = await promiseAll(castPromises); const payload: ServerStateSyncFullSocketPayload = { type: stateSyncPayloadTypes.FULL, messagesResult, threadInfos: results.threadInfos, currentUserInfo: results.currentUserInfo, rawEntryInfos: results.entryInfos, userInfos: results.userInfos, updatesCurrentAsOf: oldUpdatesCurrentAsOf, }; if (viewer.sessionChanged) { // If initializeSession encounters, // sessionIdentifierTypes.BODY_SESSION_ID but the session // is unspecified or expired, // it will set a new sessionID and specify viewer.sessionChanged const { sessionID } = viewer; invariant( sessionID !== null && sessionID !== undefined, 'should be set', ); payload.sessionID = sessionID; viewer.sessionChanged = false; } responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload, }); } else { const { sessionUpdate, deltaEntryInfoResult } = sessionInitializationResult; const deleteExpiredUpdatesPromise = deleteUpdatesBeforeTimeTargetingSession(viewer, oldUpdatesCurrentAsOf); const fetchUpdateResultPromise = fetchUpdateInfos( viewer, oldUpdatesCurrentAsOf, calendarQuery, ); const sessionUpdatePromise = commitSessionUpdate(viewer, sessionUpdate); const [fetchUpdateResult] = await Promise.all([ fetchUpdateResultPromise, deleteExpiredUpdatesPromise, sessionUpdatePromise, ]); const { updateInfos, userInfos } = fetchUpdateResult; const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp( [...updateInfos], oldUpdatesCurrentAsOf, ); const updatesResult = { newUpdates: updateInfos, currentAsOf: newUpdatesCurrentAsOf, }; responses.push({ type: serverSocketMessageTypes.STATE_SYNC, responseTo: message.id, payload: { type: stateSyncPayloadTypes.INCREMENTAL, messagesResult, updatesResult, deltaEntryInfos: deltaEntryInfoResult.rawEntryInfos, deletedEntryIDs: deltaEntryInfoResult.deletedEntryIDs, userInfos: values(userInfos), }, }); } const [signedIdentityKeysBlobMissing, olmNotificationsSessionMissing] = await Promise.all([ isCookieMissingSignedIdentityKeysBlobPromise, isCookieMissingOlmNotificationsSessionPromise, ]); if (signedIdentityKeysBlobMissing) { serverRequests.push({ type: serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, }); } if (olmNotificationsSessionMissing) { serverRequests.push({ type: serverRequestTypes.INITIAL_NOTIFICATIONS_ENCRYPTED_MESSAGE, }); } if (serverRequests.length > 0 || clientResponses.length > 0) { // We send this message first since the STATE_SYNC triggers the client's // connection status to shift to "connected", and we want to make sure the // client responses are cleared from Redux before that happens responses.unshift({ type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }); } if (activityUpdateResult) { // Same reason for unshifting as above responses.unshift({ type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, responseTo: message.id, payload: activityUpdateResult, }); } return responses; } async handleResponsesClientSocketMessage( message: ResponsesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { clientResponses } = message.payload; const { stateCheckStatus } = await processClientResponses( viewer, clientResponses, ); const serverRequests = []; if (stateCheckStatus && stateCheckStatus.status !== 'state_check') { const { sessionUpdate, checkStateRequest } = await checkState( viewer, stateCheckStatus, ); if (sessionUpdate) { await commitSessionUpdate(viewer, sessionUpdate); this.setStateCheckConditions({ stateCheckOngoing: false }); } if (checkStateRequest) { serverRequests.push(checkStateRequest); } } // We send a response message regardless of whether we have any requests, // since we need to ack the client's responses return [ { type: serverSocketMessageTypes.REQUESTS, responseTo: message.id, payload: { serverRequests }, }, ]; } handlePingClientSocketMessage( message: PingClientSocketMessage, ): ServerServerSocketMessage[] { return [ { type: serverSocketMessageTypes.PONG, responseTo: message.id, }, ]; } async handleAckUpdatesClientSocketMessage( message: AckUpdatesClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const { currentAsOf } = message.payload; await Promise.all([ deleteUpdatesBeforeTimeTargetingSession(viewer, currentAsOf), commitSessionUpdate(viewer, { lastUpdate: currentAsOf }), ]); return []; } async handleAPIRequestClientSocketMessage( message: APIRequestClientSocketMessage, ): Promise { if (!endpointIsSocketSafe(message.payload.endpoint)) { throw new ServerError('endpoint_unsafe_for_socket'); } const { viewer } = this; invariant(viewer, 'should be set'); const responder = jsonEndpoints[message.payload.endpoint]; await policiesValidator(viewer, responder.requiredPolicies); const response = await responder.responder(viewer, message.payload.input); return [ { type: serverSocketMessageTypes.API_RESPONSE, responseTo: message.id, payload: response, }, ]; } onRedisMessage = async (message: RedisMessage) => { try { await this.processRedisMessage(message); } catch (e) { console.warn(e); } }; async processRedisMessage(message: RedisMessage) { if (message.type === redisMessageTypes.START_SUBSCRIPTION) { this.ws.terminate(); } else if (message.type === redisMessageTypes.NEW_UPDATES) { const { viewer } = this; invariant(viewer, 'should be set'); if (message.ignoreSession && message.ignoreSession === viewer.session) { return; } const rawUpdateInfos = message.updates; this.redisPromiseResolver.add( (async () => { const { updateInfos, userInfos } = await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, { viewer, }); if (updateInfos.length === 0) { console.warn( 'could not get any UpdateInfos from redisMessageTypes.NEW_UPDATES', ); return null; } this.markActivityOccurred(); return { type: serverSocketMessageTypes.UPDATES, payload: { updatesResult: { currentAsOf: mostRecentUpdateTimestamp([...updateInfos], 0), newUpdates: updateInfos, }, userInfos: values(userInfos), }, }; })(), ); } else if (message.type === redisMessageTypes.NEW_MESSAGES) { const { viewer } = this; invariant(viewer, 'should be set'); const rawMessageInfos = message.messages; const messageFetchResult = getMessageFetchResultFromRedisMessages( viewer, rawMessageInfos, ); if (messageFetchResult.rawMessageInfos.length === 0) { console.warn( 'could not get any rawMessageInfos from ' + 'redisMessageTypes.NEW_MESSAGES', ); return; } this.redisPromiseResolver.add( (async () => { this.markActivityOccurred(); return { type: serverSocketMessageTypes.MESSAGES, payload: { messagesResult: { rawMessageInfos: messageFetchResult.rawMessageInfos, truncationStatuses: messageFetchResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( messageFetchResult.rawMessageInfos, 0, ), }, }, }; })(), ); } } onSuccessfulConnection() { if (this.ws.readyState !== 1) { return; } this.handleStateCheckConditionsUpdate(); } // The Socket will timeout by calling this.ws.terminate() // serverRequestSocketTimeout milliseconds after the last // time resetTimeout is called resetTimeout: { +cancel: () => void } & (() => void) = _debounce( () => this.ws.terminate(), serverRequestSocketTimeout, ); debouncedAfterActivity: { +cancel: () => void } & (() => void) = _debounce( () => this.setStateCheckConditions({ activityRecentlyOccurred: false }), stateCheckInactivityActivationInterval, ); markActivityOccurred = () => { if (this.ws.readyState !== 1) { return; } this.setStateCheckConditions({ activityRecentlyOccurred: true }); this.debouncedAfterActivity(); }; clearStateCheckTimeout() { const { stateCheckTimeoutID } = this; if (stateCheckTimeoutID) { clearTimeout(stateCheckTimeoutID); this.stateCheckTimeoutID = null; } } setStateCheckConditions(newConditions: Partial) { this.stateCheckConditions = { ...this.stateCheckConditions, ...newConditions, }; this.handleStateCheckConditionsUpdate(); } get stateCheckCanStart(): boolean { return Object.values(this.stateCheckConditions).every(cond => !cond); } handleStateCheckConditionsUpdate() { if (!this.stateCheckCanStart) { this.clearStateCheckTimeout(); return; } if (this.stateCheckTimeoutID) { return; } const { viewer } = this; if (!viewer) { return; } const timeUntilStateCheck = viewer.sessionLastValidated + sessionCheckFrequency - Date.now(); if (timeUntilStateCheck <= 0) { ignorePromiseRejections(this.initiateStateCheck()); } else { this.stateCheckTimeoutID = setTimeout( this.initiateStateCheck, timeUntilStateCheck, ); } } initiateStateCheck = async () => { this.setStateCheckConditions({ stateCheckOngoing: true }); const { viewer } = this; invariant(viewer, 'should be set'); const { checkStateRequest } = await checkState(viewer, { status: 'state_check', }); invariant(checkStateRequest, 'should be set'); await this.sendMessage({ type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [checkStateRequest] }, }); }; } export { onConnection }; diff --git a/lib/types/filter-types.js b/lib/types/filter-types.js index 33a9e6d06..578a277f9 100644 --- a/lib/types/filter-types.js +++ b/lib/types/filter-types.js @@ -1,46 +1,48 @@ // @flow import t, { type TUnion } from 'tcomb'; import type { ResolvedThreadInfo } from './minimally-encoded-thread-permissions-types.js'; import { tID, tShape, tString } from '../utils/validation-utils.js'; export const calendarThreadFilterTypes = Object.freeze({ THREAD_LIST: 'threads', NOT_DELETED: 'not_deleted', }); export type CalendarThreadFilterType = $Values< typeof calendarThreadFilterTypes, >; export type CalendarThreadFilter = { +type: 'threads', +threadIDs: $ReadOnlyArray, }; export type NotDeletedFilter = { +type: 'not_deleted' }; export type CalendarFilter = NotDeletedFilter | CalendarThreadFilter; export const calendarFilterValidator: TUnion = t.union([ tShape({ - type: tString('threads'), + type: tString(calendarThreadFilterTypes.THREAD_LIST), threadIDs: t.list(tID), }), - tShape({ type: tString('not_deleted') }), + tShape({ + type: tString(calendarThreadFilterTypes.NOT_DELETED), + }), ]); export const defaultCalendarFilters: $ReadOnlyArray = [ { type: calendarThreadFilterTypes.NOT_DELETED }, ]; export const updateCalendarThreadFilter = 'UPDATE_CALENDAR_THREAD_FILTER'; export const clearCalendarThreadFilter = 'CLEAR_CALENDAR_THREAD_FILTER'; export const setCalendarDeletedFilter = 'SET_CALENDAR_DELETED_FILTER'; export type SetCalendarDeletedFilterPayload = { +includeDeleted: boolean, }; export type FilterThreadInfo = { +threadInfo: ResolvedThreadInfo, +numVisibleEntries: number, };