diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js index c826a1b80..5f25e0284 100644 --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -1,231 +1,232 @@ // @flow import type { RawMessageInfo } from './message-types'; import type { AccountUserInfo } from './user-types'; import { type CalendarFilter, calendarFilterPropType, defaultCalendarFilters, } from './filter-types'; import type { CreateUpdatesResponse } from './update-types'; import type { Platform } from './device-types'; import type { ClientEntryInconsistencyReportCreationRequest } from './report-types'; import PropTypes from 'prop-types'; import { fifteenDaysEarlier, fifteenDaysLater, thisMonthDates, } from '../utils/date-utils'; export type RawEntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creatorID: string, deleted: boolean, |}; export const rawEntryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creatorID: PropTypes.string.isRequired, deleted: PropTypes.bool.isRequired, }); export type EntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creator: ?string, deleted: boolean, |}; export const entryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creator: PropTypes.string, deleted: PropTypes.bool.isRequired, }); export type EntryStore = {| entryInfos: { [id: string]: RawEntryInfo }, daysToEntries: { [day: string]: string[] }, lastUserInteractionCalendar: number, inconsistencyReports: $ReadOnlyArray, |}; export type CalendarQuery = {| startDate: string, endDate: string, filters: $ReadOnlyArray, |}; export const defaultCalendarQuery = ( platform: ?Platform, timeZone?: ?string, ) => { if (platform === 'web') { return { ...thisMonthDates(timeZone), filters: defaultCalendarFilters, }; } else { return { startDate: fifteenDaysEarlier(timeZone).valueOf(), endDate: fifteenDaysLater(timeZone).valueOf(), filters: defaultCalendarFilters, }; } }; export const calendarQueryPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, filters: PropTypes.arrayOf(calendarFilterPropType).isRequired, }); export type SaveEntryInfo = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery: CalendarQuery, |}; export type SaveEntryRequest = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type SaveEntryResponse = {| entryID: string, newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type SaveEntryPayload = {| ...SaveEntryResponse, threadID: string, |}; export type CreateEntryInfo = {| text: string, timestamp: number, date: string, threadID: string, localID: string, calendarQuery: CalendarQuery, |}; export type CreateEntryRequest = {| text: string, timestamp: number, date: string, threadID: string, localID?: string, calendarQuery?: CalendarQuery, |}; export type CreateEntryPayload = {| ...SaveEntryPayload, localID: string, |}; export type DeleteEntryInfo = {| entryID: string, prevText: string, calendarQuery: CalendarQuery, |}; export type DeleteEntryRequest = {| entryID: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type RestoreEntryInfo = {| entryID: string, calendarQuery: CalendarQuery, |}; export type RestoreEntryRequest = {| entryID: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type DeleteEntryResponse = {| newMessageInfos: $ReadOnlyArray, threadID: string, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryResponse = {| newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryPayload = {| ...RestoreEntryResponse, threadID: string, |}; -export type FetchEntryInfosResponse = {| +export type FetchEntryInfosBase = {| rawEntryInfos: $ReadOnlyArray, - userInfos: { [id: string]: AccountUserInfo }, |}; -export type FetchEntryInfosResult = {| - rawEntryInfos: $ReadOnlyArray, +export type FetchEntryInfosResponse = {| + ...FetchEntryInfosBase, + userInfos: { [id: string]: AccountUserInfo }, |}; +export type FetchEntryInfosResult = FetchEntryInfosBase; export type DeltaEntryInfosResponse = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, |}; export type DeltaEntryInfosResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type CalendarResult = {| rawEntryInfos: $ReadOnlyArray, calendarQuery: CalendarQuery, |}; export type CalendarQueryUpdateStartingPayload = {| calendarQuery?: CalendarQuery, |}; export type CalendarQueryUpdateResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, calendarQuery: CalendarQuery, calendarQueryAlreadyUpdated: boolean, |}; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index a1d812558..70ba7471b 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,784 +1,788 @@ // @flow import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypePropType, } from './thread-types'; import { type RelativeUserInfo, relativeUserInfoPropType, type UserInfos, } from './user-types'; import { type Media, type Image, mediaPropType } from './media-types'; import invariant from 'invariant'; import PropTypes from 'prop-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type TextMessageData = {| type: 0, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, text: string, |}; type CreateThreadMessageData = {| type: 1, threadID: string, creatorID: string, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], |}, |}; type AddMembersMessageData = {| type: 2, threadID: string, creatorID: string, time: number, addedUserIDs: string[], |}; type CreateSubthreadMessageData = {| type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, |}; type ChangeSettingsMessageData = {| type: 4, threadID: string, creatorID: string, time: number, field: string, value: string | number, |}; type RemoveMembersMessageData = {| type: 5, threadID: string, creatorID: string, time: number, removedUserIDs: string[], |}; type ChangeRoleMessageData = {| type: 6, threadID: string, creatorID: string, time: number, userIDs: string[], newRole: string, |}; type LeaveThreadMessageData = {| type: 7, threadID: string, creatorID: string, time: number, |}; type JoinThreadMessageData = {| type: 8, threadID: string, creatorID: string, time: number, |}; type CreateEntryMessageData = {| type: 9, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type EditEntryMessageData = {| type: 10, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type DeleteEntryMessageData = {| type: 11, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type RestoreEntryMessageData = {| type: 12, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; export type ImagesMessageData = {| type: 14, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MediaMessageData = {| type: 15, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawTextMessageInfo = {| ...TextMessageData, id?: string, // null if local copy without ID yet |}; export type RawImagesMessageInfo = {| ...ImagesMessageData, id?: string, // null if local copy without ID yet |}; export type RawMediaMessageInfo = {| ...MediaMessageData, id?: string, // null if local copy without ID yet |}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = | {| ...CreateThreadMessageData, id: string, |} | {| ...AddMembersMessageData, id: string, |} | {| ...CreateSubthreadMessageData, id: string, |} | {| ...ChangeSettingsMessageData, id: string, |} | {| ...RemoveMembersMessageData, id: string, |} | {| ...ChangeRoleMessageData, id: string, |} | {| ...LeaveThreadMessageData, id: string, |} | {| ...JoinThreadMessageData, id: string, |} | {| ...CreateEntryMessageData, id: string, |} | {| ...EditEntryMessageData, id: string, |} | {| ...DeleteEntryMessageData, id: string, |} | {| ...RestoreEntryMessageData, id: string, |} | {| type: 13, id: string, threadID: string, creatorID: string, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type RawMessageInfo = RawComposableMessageInfo | RawRobotextMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type TextMessageInfo = {| type: 0, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp text: string, |}; export type ImagesMessageInfo = {| type: 14, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MediaMessageInfo = {| type: 15, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | {| type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadInfo: ?ThreadInfo, color: string, otherMembers: RelativeUserInfo[], |}, |} | {| type: 2, id: string, threadID: string, creator: RelativeUserInfo, time: number, addedMembers: RelativeUserInfo[], |} | {| type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, childThreadInfo: ThreadInfo, |} | {| type: 4, id: string, threadID: string, creator: RelativeUserInfo, time: number, field: string, value: string | number, |} | {| type: 5, id: string, threadID: string, creator: RelativeUserInfo, time: number, removedMembers: RelativeUserInfo[], |} | {| type: 6, id: string, threadID: string, creator: RelativeUserInfo, time: number, members: RelativeUserInfo[], newRole: string, |} | {| type: 7, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 8, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 9, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 10, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 11, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 12, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 13, id: string, threadID: string, creator: RelativeUserInfo, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type MessageInfo = ComposableMessageInfo | RobotextMessageInfo; export const messageInfoPropType = PropTypes.oneOfType([ PropTypes.shape({ type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, initialThreadState: PropTypes.shape({ type: threadTypePropType.isRequired, name: PropTypes.string, parentThreadInfo: threadInfoPropType, color: PropTypes.string.isRequired, otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, childThreadInfo: threadInfoPropType.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, newRole: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, robotext: PropTypes.string.isRequired, unsupportedMessageInfo: PropTypes.object.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), ]); export type ThreadMessageInfo = {| messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp |}; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = {| sendFailed?: boolean, |}; export const localMessageInfoPropType = PropTypes.shape({ sendFailed: PropTypes.bool, }); export type MessageStore = {| messages: { [id: string]: RawMessageInfo }, threads: { [threadID: string]: ThreadMessageInfo }, local: { [id: string]: LocalMessageInfo }, currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { [threadID: string]: ?string }; export type ThreadSelectionCriteria = {| threadCursors?: ?ThreadCursors, joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| cursors: ThreadCursors, numberPerThread?: ?number, |}; -export type FetchMessageInfosResult = {| +export type FetchMessageInfosResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, |}; +export type FetchMessageInfosResult = {| + rawMessageInfos: RawMessageInfo[], + truncationStatuses: MessageTruncationStatuses, +|}; export type FetchMessageInfosPayload = {| threadID: string, rawMessageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatus, |}; export type MessagesResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| id: string, time: number, |}; export type SendMessagePayload = {| localID: string, serverID: string, threadID: string, time: number, |}; export type SendTextMessageRequest = {| threadID: string, localID?: string, text: string, |}; export type SendMultimediaMessageRequest = {| threadID: string, localID: string, mediaIDs: $ReadOnlyArray, |}; // Used for the message info included in log-in type actions export type GenericMessagesResult = {| messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: $ReadOnlyArray, currentAsOf: number, |}; export type SaveMessagesPayload = {| rawMessageInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| threadIDs: $ReadOnlyArray, |}; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 6f606a58e..163803221 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,362 +1,362 @@ // @flow import type { ThreadSubscription } from './subscription-types'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types'; -import type { UserInfo, UserInfos } from './user-types'; +import type { UserInfo, AccountUserInfo } from './user-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { UpdateInfo } from './update-types'; import type { ClientThreadInconsistencyReportCreationRequest } from './report-types'; import PropTypes from 'prop-types'; import invariant from 'invariant'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) CHAT_NESTED_OPEN: 3, CHAT_SECRET: 4, SIDEBAR: 5, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5, 'number is not ThreadType enum', ); return threadType; } // Keep in sync with server/permissions.php export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD: 'edit_thread', DELETE_THREAD: 'delete_thread', CREATE_SUBTHREADS: 'create_subthreads', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', OPEN: 'open_', OPEN_DESCENDANT: 'descendant_open_', }); export type ThreadPermissionInfo = | {| value: true, source: string |} | {| value: false, source: null |}; export type ThreadPermissionsBlob = { [permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { [permission: string]: boolean }; export type ThreadPermissionsInfo = { [permission: ThreadPermission]: ThreadPermissionInfo, } & ThreadPermissionsBlob; export const threadPermissionsInfoPropType = PropTypes.objectOf( PropTypes.oneOfType([ PropTypes.shape({ value: PropTypes.oneOf([true]), source: PropTypes.string.isRequired, }), PropTypes.shape({ value: PropTypes.oneOf([false]), source: PropTypes.oneOf([null]), }), ]), ); export type MemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, |}; export const memberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, }); export type RelativeMemberInfo = {| ...MemberInfo, username: ?string, isViewer: boolean, |}; export const relativeMemberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type RoleInfo = {| id: string, name: string, permissions: ThreadRolePermissionsBlob, isDefault: boolean, |}; export type ThreadCurrentUserInfo = {| role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type RawThreadInfo = {| id: string, type: ThreadType, visibilityRules: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, |}; export type ThreadInfo = {| id: string, type: ThreadType, name: ?string, uiName: string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, |}; export const threadTypePropType = PropTypes.oneOf([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.SIDEBAR, ]); const rolePropType = PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, permissions: PropTypes.objectOf(PropTypes.bool).isRequired, isDefault: PropTypes.bool.isRequired, }); const currentUserPropType = PropTypes.shape({ role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, subscription: PropTypes.shape({ pushNotifs: PropTypes.bool.isRequired, home: PropTypes.bool.isRequired, }).isRequired, unread: PropTypes.bool, }); export const rawThreadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, }); export const threadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, uiName: PropTypes.string.isRequired, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, }); export type ServerMemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type ServerThreadInfo = {| id: string, type: ThreadType, visibilityRules: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, |}; export type ThreadStore = {| threadInfos: { [id: string]: RawThreadInfo }, inconsistencyReports: $ReadOnlyArray, |}; export type ThreadDeletionRequest = {| threadID: string, accountPassword: string, |}; export type RemoveMembersRequest = {| threadID: string, memberIDs: $ReadOnlyArray, |}; export type RoleChangeRequest = {| threadID: string, memberIDs: $ReadOnlyArray, role: string, |}; export type ChangeThreadSettingsResult = {| threadInfo?: RawThreadInfo, threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type ChangeThreadSettingsPayload = {| threadID: string, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type LeaveThreadRequest = {| threadID: string, |}; export type LeaveThreadResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type LeaveThreadPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type ThreadChanges = $Shape<{| type: ThreadType, name: string, description: string, color: string, parentThreadID: string, newMemberIDs: $ReadOnlyArray, |}>; export type UpdateThreadRequest = {| threadID: string, changes: ThreadChanges, accountPassword?: ?string, |}; export type NewThreadRequest = {| type: ThreadType, name?: ?string, description?: ?string, color?: ?string, parentThreadID?: ?string, initialMemberIDs?: ?$ReadOnlyArray, |}; export type NewThreadResponse = {| updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, newThreadInfo?: RawThreadInfo, newThreadID?: string, |}; export type NewThreadResult = {| updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, newThreadID: string, |}; export type ServerThreadJoinRequest = {| threadID: string, calendarQuery?: ?CalendarQuery, |}; export type ClientThreadJoinRequest = {| threadID: string, calendarQuery: CalendarQuery, |}; export type ThreadJoinResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, - userInfos: UserInfos, + userInfos: { [string]: AccountUserInfo }, rawEntryInfos?: ?$ReadOnlyArray, |}; export type ThreadJoinPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, calendarResult: CalendarResult, |}; diff --git a/server/src/creators/account-creator.js b/server/src/creators/account-creator.js index 215618342..4b4490920 100644 --- a/server/src/creators/account-creator.js +++ b/server/src/creators/account-creator.js @@ -1,154 +1,159 @@ // @flow import type { RegisterResponse, RegisterRequest, } from 'lib/types/account-types'; import type { Viewer } from '../session/viewer'; import { threadTypes } from 'lib/types/thread-types'; import { messageTypes } from 'lib/types/message-types'; import bcrypt from 'twin-bcrypt'; import invariant from 'invariant'; import { validUsernameRegex, validEmailRegex } from 'lib/shared/account-utils'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import ashoat from 'lib/facts/ashoat'; import { dbQuery, SQL } from '../database'; import createIDs from './id-creator'; import { createNewUserCookie, setNewSession } from '../session/cookies'; import { deleteCookie } from '../deleters/cookie-deleters'; import { sendEmailAddressVerificationEmail } from '../emails/verification'; import createMessages from './message-creator'; import createThread from './thread-creator'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; +import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; const ashoatMessages = [ 'welcome to SquadCal! thanks for helping to test the alpha.', 'as you inevitably discover bugs, have feature requests, or design ' + 'suggestions, feel free to message them to me in the app.', ]; async function createAccount( viewer: Viewer, request: RegisterRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } if (request.username.search(validUsernameRegex) === -1) { throw new ServerError('invalid_username'); } if (request.email.search(validEmailRegex) === -1) { throw new ServerError('invalid_email'); } const usernameQuery = SQL` SELECT COUNT(id) AS count FROM users WHERE LCASE(username) = LCASE(${request.username}) `; const emailQuery = SQL` SELECT COUNT(id) AS count FROM users WHERE LCASE(email) = LCASE(${request.email}) `; const promises = [dbQuery(usernameQuery), dbQuery(emailQuery)]; const { calendarQuery } = request; if (calendarQuery) { promises.push(verifyCalendarQueryThreadIDs(calendarQuery)); } const [[usernameResult], [emailResult]] = await Promise.all(promises); if (usernameResult[0].count !== 0) { throw new ServerError('username_taken'); } if (emailResult[0].count !== 0) { throw new ServerError('email_taken'); } const hash = bcrypt.hashSync(request.password); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.username, hash, request.email, time]; const newUserQuery = SQL` INSERT INTO users(id, username, hash, email, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), sendEmailAddressVerificationEmail( id, request.username, request.email, true, ), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, 0); } const [personalThreadResult, ashoatThreadResult] = await Promise.all([ createThread( viewer, { type: threadTypes.CHAT_SECRET, name: request.username, description: 'your personal calendar', }, true, ), createThread( viewer, { type: threadTypes.CHAT_SECRET, initialMemberIDs: [ashoat.id], }, true, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadInfo ? ashoatThreadResult.newThreadInfo.id : ashoatThreadResult.newThreadID; invariant( ashoatThreadID, 'createThread should return either newThreadInfo or newThreadID', ); let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); - const ashoatMessageInfos = await createMessages(viewer, ashoatMessageDatas); + const [ashoatMessageInfos, threadsResult, userInfos] = await Promise.all([ + createMessages(viewer, ashoatMessageDatas), + fetchThreadInfos(viewer), + fetchKnownUserInfos(viewer), + ]); const rawMessageInfos = [ ...personalThreadResult.newMessageInfos, ...ashoatThreadResult.newMessageInfos, ...ashoatMessageInfos, ]; - const threadsResult = await fetchThreadInfos(viewer); - const userInfos = values({ ...threadsResult.userInfos }); - return { id, rawMessageInfos, - cookieChange: { threadInfos: threadsResult.threadInfos, userInfos }, + cookieChange: { + threadInfos: threadsResult.threadInfos, + userInfos: values(userInfos), + }, }; } export default createAccount; diff --git a/server/src/creators/update-creator.js b/server/src/creators/update-creator.js index c6169398c..eb068829a 100644 --- a/server/src/creators/update-creator.js +++ b/server/src/creators/update-creator.js @@ -1,690 +1,689 @@ // @flow import { type UpdateInfo, type UpdateData, type RawUpdateInfo, type CreateUpdatesResult, updateTypes, } from 'lib/types/update-types'; import type { Viewer } from '../session/viewer'; import type { RawThreadInfo } from 'lib/types/thread-types'; import type { AccountUserInfo, LoggedInUserInfo } from 'lib/types/user-types'; import { defaultNumberPerThread, type FetchMessageInfosResult, } from 'lib/types/message-types'; import { type RawEntryInfo, - type FetchEntryInfosResponse, + type FetchEntryInfosBase, type CalendarQuery, defaultCalendarQuery, } from 'lib/types/entry-types'; import { type UpdateTarget, redisMessageTypes, type NewUpdatesRedisMessage, } from 'lib/types/redis-types'; import invariant from 'invariant'; import _uniq from 'lodash/fp/uniq'; import _intersection from 'lodash/fp/intersection'; import { promiseAll } from 'lib/utils/promises'; import { nonThreadCalendarFilters } from 'lib/selectors/calendar-filter-selectors'; import { keyForUpdateData, keyForUpdateInfo, conditionKeyForUpdateData, conditionKeyForUpdateDataFromKey, rawUpdateInfoFromUpdateData, } from 'lib/shared/update-utils'; import { dbQuery, SQL, SQLStatement, mergeAndConditions } from '../database'; import createIDs from './id-creator'; import { deleteUpdatesByConditions } from '../deleters/update-deleters'; import { fetchThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchEntryInfos, fetchEntryInfosByID, } from '../fetchers/entry-fetchers'; import { fetchKnownUserInfos, fetchLoggedInUserInfos, } from '../fetchers/user-fetchers'; import { channelNameForUpdateTarget, publisher } from '../socket/redis'; import { handleAsyncPromise } from '../responders/handlers'; type UpdatesForCurrentSession = // This is the default if no Viewer is passed, or if an isSocket Viewer is // passed in. We will broadcast to all valid sessions via Redis and return // nothing to the caller, relying on the current session's Redis listener to // pick up the updates and deliver them asynchronously. | 'broadcast' // This is the default if a non-isSocket Viewer is passed in. We avoid // broadcasting the update to the current session, and instead return the // update to the caller, who will handle delivering it to the client. | 'return' // This means we ignore any updates destined for the current session. // Presumably the caller knows what they are doing and has a different way of // communicating the relevant information to the client. | 'ignore'; export type ViewerInfo = | {| viewer: Viewer, calendarQuery?: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, |} | {| viewer: Viewer, calendarQuery: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, threadInfos: { [id: string]: RawThreadInfo }, - userInfos: { [id: string]: AccountUserInfo }, |}; const emptyArray = []; const defaultUpdateCreationResult = { viewerUpdates: [], userInfos: {} }; const sortFunction = (a: UpdateData | UpdateInfo, b: UpdateData | UpdateInfo) => a.time - b.time; // Creates rows in the updates table based on the inputed updateDatas. Returns // UpdateInfos pertaining to the provided viewerInfo, as well as related // UserInfos. If no viewerInfo is provided, no UpdateInfos will be returned. And // the update row won't have an updater column, meaning no session will be // excluded from the update. async function createUpdates( updateDatas: $ReadOnlyArray, passedViewerInfo?: ?ViewerInfo, ): Promise { if (updateDatas.length === 0) { return defaultUpdateCreationResult; } // viewer.session will throw for a script Viewer let viewerInfo = passedViewerInfo; if ( viewerInfo && (viewerInfo.viewer.isScriptViewer || !viewerInfo.viewer.loggedIn) ) { viewerInfo = null; } const sortedUpdateDatas = [...updateDatas].sort(sortFunction); const filteredUpdateDatas: UpdateData[] = []; const keyedUpdateDatas: Map = new Map(); const deleteConditions: Map = new Map(); for (let updateData of sortedUpdateDatas) { // If we don't end up `continue`ing below, types indicates which // update types we should delete for the corresponding key let types; if (updateData.type === updateTypes.DELETE_ACCOUNT) { types = [updateTypes.DELETE_ACCOUNT, updateTypes.UPDATE_USER]; } else if (updateData.type === updateTypes.UPDATE_THREAD) { types = [ updateTypes.UPDATE_THREAD, updateTypes.UPDATE_THREAD_READ_STATUS, ]; } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { types = [updateTypes.UPDATE_THREAD_READ_STATUS]; } else if ( updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { types = []; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { types = []; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { types = [updateTypes.UPDATE_CURRENT_USER]; } else if (updateData.type === updateTypes.UPDATE_USER) { types = [updateTypes.UPDATE_USER]; } else { filteredUpdateDatas.push(updateData); continue; } const conditionKey = conditionKeyForUpdateData(updateData); invariant(conditionKey && types, 'should be set'); // Possibly filter any UpdateDatas in the current batch based on this one let keyUpdateDatas = keyedUpdateDatas.get(conditionKey); let keyUpdateDatasChanged = false; if (!keyUpdateDatas) { keyUpdateDatas = []; } else if (types.length === 0) { keyUpdateDatas = []; keyUpdateDatasChanged = true; } else { const filteredKeyUpdateDatas = keyUpdateDatas.filter( keyUpdateData => types.indexOf(keyUpdateData.type) === -1, ); if (filteredKeyUpdateDatas.length === 0) { keyUpdateDatas = []; keyUpdateDatasChanged = true; } else if (filteredKeyUpdateDatas.length !== keyUpdateDatas.length) { keyUpdateDatas = filteredKeyUpdateDatas; keyUpdateDatasChanged = true; } } // Update the deleteConditions and add our UpdateData to keyedUpdateDatas const existingTypes = deleteConditions.get(conditionKey); if (types.length === 0) { // If this UpdateData says to delete all the others, then include it, and // update the deleteConditions (if they don't already say to delete) if (!existingTypes || existingTypes.length !== 0) { deleteConditions.set(conditionKey, emptyArray); } keyUpdateDatas.push(updateData); keyUpdateDatasChanged = true; } else if (!existingTypes) { // If there were no existing conditions, then set the deleteConditions and // include this UpdateData deleteConditions.set(conditionKey, types); keyUpdateDatas.push(updateData); keyUpdateDatasChanged = true; } else { // Finally, if we have a list of types to delete, both existing and new, // then merge the list for the deleteConditions, and include this // UpdateData as long as its list of types isn't a strict subset of the // existing one. const newTypes = _uniq([...existingTypes, ...types]); deleteConditions.set(conditionKey, newTypes); const intersection = _intersection(existingTypes)(types); if ( intersection.length !== types.length || intersection.length === existingTypes.length ) { keyUpdateDatas.push(updateData); keyUpdateDatasChanged = true; } } if (!keyUpdateDatasChanged) { continue; } if (keyUpdateDatas.length === 0) { keyedUpdateDatas.delete(conditionKey); } else { keyedUpdateDatas.set(conditionKey, keyUpdateDatas); } } for (let [, singleUpdateDatas] of keyedUpdateDatas) { filteredUpdateDatas.push(...singleUpdateDatas); } const ids = await createIDs('updates', filteredUpdateDatas.length); let updatesForCurrentSession = viewerInfo && viewerInfo.updatesForCurrentSession; if (!updatesForCurrentSession && viewerInfo) { updatesForCurrentSession = viewerInfo.viewer.isSocket ? 'broadcast' : 'return'; } else if (!updatesForCurrentSession) { updatesForCurrentSession = 'broadcast'; } const dontBroadcastSession = updatesForCurrentSession !== 'broadcast' && viewerInfo ? viewerInfo.viewer.session : null; const publishInfos: Map = new Map(); const viewerRawUpdateInfos: RawUpdateInfo[] = []; const insertRows: (?(number | string))[][] = []; const earliestTime: Map = new Map(); for (let i = 0; i < filteredUpdateDatas.length; i++) { const updateData = filteredUpdateDatas[i]; let content, target = null; if (updateData.type === updateTypes.DELETE_ACCOUNT) { content = JSON.stringify({ deletedUserID: updateData.deletedUserID }); } else if (updateData.type === updateTypes.UPDATE_THREAD) { content = JSON.stringify({ threadID: updateData.threadID }); } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { const { threadID, unread } = updateData; content = JSON.stringify({ threadID, unread }); } else if ( updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { const { threadID } = updateData; content = JSON.stringify({ threadID }); } else if (updateData.type === updateTypes.BAD_DEVICE_TOKEN) { const { deviceToken, targetCookie } = updateData; content = JSON.stringify({ deviceToken }); target = targetCookie; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { const { entryID, targetSession } = updateData; content = JSON.stringify({ entryID }); target = targetSession; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { // user column contains all the info we need to construct the UpdateInfo content = null; } else if (updateData.type === updateTypes.UPDATE_USER) { const { updatedUserID } = updateData; content = JSON.stringify({ updatedUserID }); } else { invariant(false, `unrecognized updateType ${updateData.type}`); } const rawUpdateInfo = rawUpdateInfoFromUpdateData(updateData, ids[i]); if (!target || !dontBroadcastSession || target !== dontBroadcastSession) { const updateTarget = target ? { userID: updateData.userID, sessionID: target } : { userID: updateData.userID }; const channelName = channelNameForUpdateTarget(updateTarget); let publishInfo = publishInfos.get(channelName); if (!publishInfo) { publishInfo = { updateTarget, rawUpdateInfos: [] }; publishInfos.set(channelName, publishInfo); } publishInfo.rawUpdateInfos.push(rawUpdateInfo); } if ( updatesForCurrentSession === 'return' && viewerInfo && updateData.userID === viewerInfo.viewer.id && (!target || target === viewerInfo.viewer.session) ) { viewerRawUpdateInfos.push(rawUpdateInfo); } if (viewerInfo && target && viewerInfo.viewer.session === target) { // In the case where this update is being created only for the current // session, there's no reason to insert a row into the updates table continue; } const key = keyForUpdateData(updateData); if (key) { const conditionKey = conditionKeyForUpdateDataFromKey(updateData, key); const currentEarliestTime = earliestTime.get(conditionKey); if (!currentEarliestTime || updateData.time < currentEarliestTime) { earliestTime.set(conditionKey, updateData.time); } } const insertRow = [ ids[i], updateData.userID, updateData.type, key, content, updateData.time, dontBroadcastSession, target, ]; insertRows.push(insertRow); } const deleteSQLConditions: SQLStatement[] = [...deleteConditions].map( ([conditionKey: string, types: number[]]) => { const [userID, key, target] = conditionKey.split('|'); const conditions = [SQL`u.user = ${userID}`, SQL`u.key = ${key}`]; if (target) { conditions.push(SQL`u.target = ${target}`); } if (types.length > 0) { conditions.push(SQL`u.type IN (${types})`); } const earliestTimeForCondition = earliestTime.get(conditionKey); if (earliestTimeForCondition) { conditions.push(SQL`u.time < ${earliestTimeForCondition}`); } return mergeAndConditions(conditions); }, ); const promises = {}; if (insertRows.length > 0) { const insertQuery = SQL` INSERT INTO updates(id, user, type, \`key\`, content, time, updater, target) `; insertQuery.append(SQL`VALUES ${insertRows}`); promises.insert = dbQuery(insertQuery); } if (publishInfos.size > 0) { handleAsyncPromise( redisPublish(publishInfos.values(), dontBroadcastSession), ); } if (deleteSQLConditions.length > 0) { promises.delete = deleteUpdatesByConditions(deleteSQLConditions); } if (viewerRawUpdateInfos.length > 0) { invariant(viewerInfo, 'should be set'); promises.updatesResult = fetchUpdateInfosWithRawUpdateInfos( viewerRawUpdateInfos, viewerInfo, ); } const { updatesResult } = await promiseAll(promises); if (!updatesResult) { return defaultUpdateCreationResult; } const { updateInfos, userInfos } = updatesResult; return { viewerUpdates: updateInfos, userInfos }; } export type FetchUpdatesResult = {| updateInfos: $ReadOnlyArray, userInfos: { [id: string]: AccountUserInfo }, |}; async function fetchUpdateInfosWithRawUpdateInfos( rawUpdateInfos: $ReadOnlyArray, viewerInfo: ViewerInfo, ): Promise { const { viewer } = viewerInfo; const threadIDsNeedingFetch = new Set(); const entryIDsNeedingFetch = new Set(); const currentUserIDsNeedingFetch = new Set(); const threadIDsNeedingDetailedFetch = new Set(); // entries and messages for (let rawUpdateInfo of rawUpdateInfos) { if ( !viewerInfo.threadInfos && (rawUpdateInfo.type === updateTypes.UPDATE_THREAD || rawUpdateInfo.type === updateTypes.JOIN_THREAD) ) { threadIDsNeedingFetch.add(rawUpdateInfo.threadID); } if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) { threadIDsNeedingDetailedFetch.add(rawUpdateInfo.threadID); } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) { entryIDsNeedingFetch.add(rawUpdateInfo.entryID); } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) { currentUserIDsNeedingFetch.add(viewer.userID); } } const promises = {}; if (!viewerInfo.threadInfos && threadIDsNeedingFetch.size > 0) { promises.threadResult = fetchThreadInfos( viewer, SQL`t.id IN (${[...threadIDsNeedingFetch]})`, ); } let calendarQuery: ?CalendarQuery = viewerInfo.calendarQuery ? viewerInfo.calendarQuery : null; if (!calendarQuery && viewer.hasSessionInfo) { // This should only ever happen for "legacy" clients who call in without // providing this information. These clients wouldn't know how to deal with // the corresponding UpdateInfos anyways, so no reason to be worried. calendarQuery = viewer.calendarQuery; } else if (!calendarQuery) { calendarQuery = defaultCalendarQuery(viewer.platform, viewer.timeZone); } if (threadIDsNeedingDetailedFetch.size > 0) { const threadSelectionCriteria = { threadCursors: {} }; for (let threadID of threadIDsNeedingDetailedFetch) { threadSelectionCriteria.threadCursors[threadID] = false; } promises.messageInfosResult = fetchMessageInfos( viewer, threadSelectionCriteria, defaultNumberPerThread, ); const threadCalendarQuery = { ...calendarQuery, filters: [ ...nonThreadCalendarFilters(calendarQuery.filters), { type: 'threads', threadIDs: [...threadIDsNeedingDetailedFetch] }, ], }; promises.calendarResult = fetchEntryInfos(viewer, [threadCalendarQuery]); } if (entryIDsNeedingFetch.size > 0) { promises.entryInfosResult = fetchEntryInfosByID(viewer, [ ...entryIDsNeedingFetch, ]); } if (currentUserIDsNeedingFetch.size > 0) { promises.currentUserInfosResult = fetchLoggedInUserInfos([ ...currentUserIDsNeedingFetch, ]); } const { threadResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfosResult, } = await promiseAll(promises); let threadInfosResult; if (viewerInfo.threadInfos) { - const { threadInfos, userInfos } = viewerInfo; - threadInfosResult = { threadInfos, userInfos }; + const { threadInfos } = viewerInfo; + threadInfosResult = { threadInfos }; } else if (threadResult) { threadInfosResult = threadResult; } else { - threadInfosResult = { threadInfos: {}, userInfos: {} }; + threadInfosResult = { threadInfos: {} }; } return await updateInfosFromRawUpdateInfos(viewer, rawUpdateInfos, { threadInfosResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfosResult, }); } export type UpdateInfosRawData = {| threadInfosResult: FetchThreadInfosResult, messageInfosResult: ?FetchMessageInfosResult, - calendarResult: ?FetchEntryInfosResponse, + calendarResult: ?FetchEntryInfosBase, entryInfosResult: ?$ReadOnlyArray, currentUserInfosResult: ?$ReadOnlyArray, |}; async function updateInfosFromRawUpdateInfos( viewer: Viewer, rawUpdateInfos: $ReadOnlyArray, rawData: UpdateInfosRawData, ): Promise { const { threadInfosResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfosResult, } = rawData; const updateInfos = []; const userIDsToFetch = new Set(); for (let rawUpdateInfo of rawUpdateInfos) { if (rawUpdateInfo.type === updateTypes.DELETE_ACCOUNT) { updateInfos.push({ type: updateTypes.DELETE_ACCOUNT, id: rawUpdateInfo.id, time: rawUpdateInfo.time, deletedUserID: rawUpdateInfo.deletedUserID, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD) { const threadInfo = threadInfosResult.threadInfos[rawUpdateInfo.threadID]; invariant(threadInfo, 'should be set'); updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS) { updateInfos.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadID: rawUpdateInfo.threadID, unread: rawUpdateInfo.unread, }); } else if (rawUpdateInfo.type === updateTypes.DELETE_THREAD) { updateInfos.push({ type: updateTypes.DELETE_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadID: rawUpdateInfo.threadID, }); } else if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) { const threadInfo = threadInfosResult.threadInfos[rawUpdateInfo.threadID]; invariant(threadInfo, 'should be set'); const rawEntryInfos = []; invariant(calendarResult, 'should be set'); for (let entryInfo of calendarResult.rawEntryInfos) { if (entryInfo.threadID === rawUpdateInfo.threadID) { rawEntryInfos.push(entryInfo); } } const rawMessageInfos = []; invariant(messageInfosResult, 'should be set'); for (let messageInfo of messageInfosResult.rawMessageInfos) { if (messageInfo.threadID === rawUpdateInfo.threadID) { rawMessageInfos.push(messageInfo); } } updateInfos.push({ type: updateTypes.JOIN_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadInfo, rawMessageInfos, truncationStatus: messageInfosResult.truncationStatuses[rawUpdateInfo.threadID], rawEntryInfos, }); } else if (rawUpdateInfo.type === updateTypes.BAD_DEVICE_TOKEN) { updateInfos.push({ type: updateTypes.BAD_DEVICE_TOKEN, id: rawUpdateInfo.id, time: rawUpdateInfo.time, deviceToken: rawUpdateInfo.deviceToken, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) { invariant(entryInfosResult, 'should be set'); const entryInfo = entryInfosResult.find( candidate => candidate.id === rawUpdateInfo.entryID, ); invariant(entryInfo, 'should be set'); updateInfos.push({ type: updateTypes.UPDATE_ENTRY, id: rawUpdateInfo.id, time: rawUpdateInfo.time, entryInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) { invariant(currentUserInfosResult, 'should be set'); const currentUserInfo = currentUserInfosResult.find( candidate => candidate.id === viewer.userID, ); invariant(currentUserInfo, 'should be set'); updateInfos.push({ type: updateTypes.UPDATE_CURRENT_USER, id: rawUpdateInfo.id, time: rawUpdateInfo.time, currentUserInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_USER) { updateInfos.push({ type: updateTypes.UPDATE_USER, id: rawUpdateInfo.id, time: rawUpdateInfo.time, updatedUserID: rawUpdateInfo.updatedUserID, }); userIDsToFetch.add(rawUpdateInfo.updatedUserID); } else { invariant(false, `unrecognized updateType ${rawUpdateInfo.type}`); } } let userInfos = {}; if (userIDsToFetch.size > 0) { userInfos = await fetchKnownUserInfos(viewer, [...userIDsToFetch]); } updateInfos.sort(sortFunction); // Now we'll attempt to merge UpdateInfos so that we only have one per key const updateForKey: Map = new Map(); const mergedUpdates: UpdateInfo[] = []; for (let updateInfo of updateInfos) { const key = keyForUpdateInfo(updateInfo); if (!key) { mergedUpdates.push(updateInfo); continue; } else if ( updateInfo.type === updateTypes.DELETE_THREAD || updateInfo.type === updateTypes.JOIN_THREAD || updateInfo.type === updateTypes.DELETE_ACCOUNT ) { updateForKey.set(key, updateInfo); continue; } const currentUpdateInfo = updateForKey.get(key); if (!currentUpdateInfo) { updateForKey.set(key, updateInfo); } else if ( updateInfo.type === updateTypes.UPDATE_THREAD && currentUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS ) { // UPDATE_THREAD trumps UPDATE_THREAD_READ_STATUS // Note that we keep the oldest UPDATE_THREAD updateForKey.set(key, updateInfo); } else if ( updateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS && currentUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS ) { // If we only have UPDATE_THREAD_READ_STATUS, keep the most recent updateForKey.set(key, updateInfo); } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { updateForKey.set(key, updateInfo); } else if (updateInfo.type === updateTypes.UPDATE_CURRENT_USER) { updateForKey.set(key, updateInfo); } } for (let [, updateInfo] of updateForKey) { mergedUpdates.push(updateInfo); } mergedUpdates.sort(sortFunction); return { updateInfos: mergedUpdates, userInfos }; } type PublishInfo = {| updateTarget: UpdateTarget, rawUpdateInfos: RawUpdateInfo[], |}; async function redisPublish( publishInfos: Iterator, dontBroadcastSession: ?string, ): Promise { for (let publishInfo of publishInfos) { const { updateTarget, rawUpdateInfos } = publishInfo; const redisMessage: NewUpdatesRedisMessage = { type: redisMessageTypes.NEW_UPDATES, updates: rawUpdateInfos, }; if (!updateTarget.sessionID && dontBroadcastSession) { redisMessage.ignoreSession = dontBroadcastSession; } publisher.sendMessage(updateTarget, redisMessage); } } export { createUpdates, fetchUpdateInfosWithRawUpdateInfos }; diff --git a/server/src/fetchers/entry-fetchers.js b/server/src/fetchers/entry-fetchers.js index 6ccae138f..99dfb1474 100644 --- a/server/src/fetchers/entry-fetchers.js +++ b/server/src/fetchers/entry-fetchers.js @@ -1,319 +1,318 @@ // @flow import type { CalendarQuery, - FetchEntryInfosResponse, + FetchEntryInfosBase, DeltaEntryInfosResponse, RawEntryInfo, } from 'lib/types/entry-types'; import type { HistoryRevisionInfo } from 'lib/types/history-types'; import type { Viewer } from '../session/viewer'; import { threadPermissions, type ThreadPermission, } from 'lib/types/thread-types'; import { calendarThreadFilterTypes } from 'lib/types/filter-types'; import invariant from 'invariant'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { ServerError } from 'lib/utils/errors'; import { filteredThreadIDs, filterExists, nonExcludeDeletedCalendarFilters, } from 'lib/selectors/calendar-filter-selectors'; import { rawEntryInfoWithinCalendarQuery } from 'lib/shared/entry-utils'; import { dbQuery, SQL, SQLStatement, mergeAndConditions, mergeOrConditions, } from '../database'; import { creationString } from '../utils/idempotent'; async function fetchEntryInfo( viewer: Viewer, entryID: string, ): Promise { const results = await fetchEntryInfosByID(viewer, [entryID]); if (results.length === 0) { return null; } return results[0]; } function rawEntryInfoFromRow(row: Object): RawEntryInfo { return { id: row.id.toString(), threadID: row.threadID.toString(), text: row.text, year: row.year, month: row.month, day: row.day, creationTime: row.creationTime, creatorID: row.creatorID.toString(), deleted: !!row.deleted, }; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchEntryInfosByID( viewer: Viewer, entryIDs: $ReadOnlyArray, ): Promise { if (entryIDs.length === 0) { return []; } const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE e.id IN (${entryIDs}) AND JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE `; const [result] = await dbQuery(query); return result.map(rawEntryInfoFromRow); } function sqlConditionForCalendarQuery( calendarQuery: CalendarQuery, ): ?SQLStatement { const { filters, startDate, endDate } = calendarQuery; const conditions = []; conditions.push(SQL`d.date BETWEEN ${startDate} AND ${endDate}`); const filterToThreadIDs = filteredThreadIDs(filters); if (filterToThreadIDs && filterToThreadIDs.size > 0) { conditions.push(SQL`d.thread IN (${[...filterToThreadIDs]})`); } else if (filterToThreadIDs) { // Filter to empty set means the result is empty return null; } else { conditions.push(SQL`m.role != 0`); } if (filterExists(filters, calendarThreadFilterTypes.NOT_DELETED)) { conditions.push(SQL`e.deleted = 0`); } return mergeAndConditions(conditions); } async function fetchEntryInfos( viewer: Viewer, calendarQueries: $ReadOnlyArray, -): Promise { +): Promise { const queryConditions = calendarQueries .map(sqlConditionForCalendarQuery) .filter(condition => condition); if (queryConditions.length === 0) { - return { rawEntryInfos: [], userInfos: {} }; + return { rawEntryInfos: [] }; } const queryCondition = mergeOrConditions(queryConditions); const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, - e.deleted, e.creator AS creatorID, u.username AS creator + e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} - LEFT JOIN users u ON u.id = e.creator WHERE JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE AND `; query.append(queryCondition); query.append(SQL`ORDER BY e.creation_time DESC`); const [result] = await dbQuery(query); const rawEntryInfos = []; for (let row of result) { rawEntryInfos.push(rawEntryInfoFromRow(row)); } - return { rawEntryInfos, userInfos: {} }; + return { rawEntryInfos }; } async function checkThreadPermissionForEntry( viewer: Viewer, entryID: string, permission: ThreadPermission, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.permissions, t.id FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN threads t ON t.id = d.thread LEFT JOIN memberships m ON m.thread = t.id AND m.user = ${viewerID} WHERE e.id = ${entryID} `; const [result] = await dbQuery(query); if (result.length === 0) { return false; } const row = result[0]; if (row.id === null) { return false; } return permissionLookup(row.permissions, permission); } async function fetchEntryRevisionInfo( viewer: Viewer, entryID: string, ): Promise<$ReadOnlyArray> { const hasPermission = await checkThreadPermissionForEntry( viewer, entryID, threadPermissions.VISIBLE, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT r.id, u.username AS author, r.text, r.last_update AS lastUpdate, r.deleted, d.thread AS threadID, r.entry AS entryID FROM revisions r LEFT JOIN users u ON u.id = r.author LEFT JOIN entries e ON e.id = r.entry LEFT JOIN days d ON d.id = e.day WHERE r.entry = ${entryID} ORDER BY r.last_update DESC `; const [result] = await dbQuery(query); const revisions = []; for (let row of result) { revisions.push({ id: row.id.toString(), author: row.author, text: row.text, lastUpdate: row.lastUpdate, deleted: !!row.deleted, threadID: row.threadID.toString(), entryID: row.entryID.toString(), }); } return revisions; } // calendarQueries are the "difference" queries we get from subtracting the old // CalendarQuery from the new one. See calendarQueryDifference. // oldCalendarQuery is the old CalendarQuery. We make sure none of the returned // RawEntryInfos match the old CalendarQuery, so that only the difference is // returned. async function fetchEntriesForSession( viewer: Viewer, calendarQueries: $ReadOnlyArray, oldCalendarQuery: CalendarQuery, ): Promise { // If we're not including deleted entries, we will try and set deletedEntryIDs // so that the client can catch possibly stale deleted entryInfos let filterDeleted = null; for (let calendarQuery of calendarQueries) { const notDeletedFilterExists = filterExists( calendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); if (filterDeleted === null) { filterDeleted = notDeletedFilterExists; } else { invariant( filterDeleted === notDeletedFilterExists, 'one of the CalendarQueries returned by calendarQueryDifference has ' + 'a NOT_DELETED filter but another does not: ' + JSON.stringify(calendarQueries), ); } } let calendarQueriesForFetch = calendarQueries; if (filterDeleted) { // Because in the filterDeleted case we still need the deleted RawEntryInfos // in order to construct deletedEntryIDs, we get rid of the NOT_DELETED // filters before passing the CalendarQueries to fetchEntryInfos. We will // filter out the deleted RawEntryInfos in a later step. calendarQueriesForFetch = calendarQueriesForFetch.map(calendarQuery => ({ ...calendarQuery, filters: nonExcludeDeletedCalendarFilters(calendarQuery.filters), })); } const { rawEntryInfos } = await fetchEntryInfos( viewer, calendarQueriesForFetch, ); const entryInfosNotInOldQuery = rawEntryInfos.filter( rawEntryInfo => !rawEntryInfoWithinCalendarQuery(rawEntryInfo, oldCalendarQuery), ); let filteredRawEntryInfos = entryInfosNotInOldQuery; let deletedEntryIDs = []; if (filterDeleted) { filteredRawEntryInfos = entryInfosNotInOldQuery.filter( rawEntryInfo => !rawEntryInfo.deleted, ); deletedEntryIDs = entryInfosNotInOldQuery .filter(rawEntryInfo => rawEntryInfo.deleted) .map(rawEntryInfo => { const { id } = rawEntryInfo; invariant( id !== null && id !== undefined, 'serverID should be set in fetchEntryInfos result', ); return id; }); } return { rawEntryInfos: filteredRawEntryInfos, deletedEntryIDs, }; } async function fetchEntryInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE e.creator = ${viewerID} AND e.creation = ${creation} AND JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawEntryInfoFromRow(result[0]); } export { fetchEntryInfo, fetchEntryInfosByID, fetchEntryInfos, checkThreadPermissionForEntry, fetchEntryRevisionInfo, fetchEntriesForSession, fetchEntryInfoForLocalID, }; diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index 78a05d596..3a6a655e0 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,729 +1,668 @@ // @flow import type { PushInfo } from '../push/send'; -import type { UserInfos } from 'lib/types/user-types'; import { type RawMessageInfo, messageTypes, type MessageType, assertMessageType, type ThreadSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, type RawTextMessageInfo, } from 'lib/types/message-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { Viewer } from '../session/viewer'; import invariant from 'invariant'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, createMediaMessageInfo, } from 'lib/shared/message-utils'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, mergeOrConditions } from '../database'; -import { fetchUserInfos } from './user-fetchers'; import { creationString, localIDFromCreationString } from '../utils/idempotent'; import { mediaFromRow } from './upload-fetchers'; export type CollapsableNotifInfo = {| collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], |}; -export type FetchCollapsableNotifsResult = {| - usersToCollapsableNotifInfo: { [userID: string]: CollapsableNotifInfo[] }, - userInfos: UserInfos, -|}; +export type FetchCollapsableNotifsResult = { + [userID: string]: CollapsableNotifInfo[], +}; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (let userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let rawMessageInfo of pushInfo[userID].messageInfos) { const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { - return { usersToCollapsableNotifInfo, userInfos: {} }; + return usersToCollapsableNotifInfo; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, - u.username AS creator, m.user AS creatorID, - stm.permissions AS subthread_permissions, n.user, n.collapse_key, - up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, - up.extra AS uploadExtra + m.user AS creatorID, stm.permissions AS subthread_permissions, n.user, + n.collapse_key, up.id AS uploadID, up.type AS uploadType, + up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user - LEFT JOIN users u ON u.id = m.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visPermissionExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC`); const [collapseResult] = await dbQuery(collapseQuery); - const { userInfos, messages } = parseMessageSQLResult(collapseResult); + const messages = parseMessageSQLResult(collapseResult); for (let message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } - return { usersToCollapsableNotifInfo, userInfos }; + return usersToCollapsableNotifInfo; } -type MessageSQLResult = {| - messages: $ReadOnlyArray<{| - rawMessageInfo: RawMessageInfo, - rows: $ReadOnlyArray, - |}>, - userInfos: UserInfos, -|}; +type MessageSQLResult = $ReadOnlyArray<{| + rawMessageInfo: RawMessageInfo, + rows: $ReadOnlyArray, +|}>; function parseMessageSQLResult( rows: $ReadOnlyArray, viewer?: Viewer, ): MessageSQLResult { - const userInfos = {}, - rowsByID = new Map(); + const rowsByID = new Map(); for (let row of rows) { - const creatorID = row.creatorID.toString(); - userInfos[creatorID] = { - id: creatorID, - username: row.creator, - }; const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (let messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows(messageRows, viewer); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } - return { messages, userInfos }; + return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map(row => row.id.toString()); console.log( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rows: $ReadOnlyArray, viewer?: ?Viewer, ): ?RawMessageInfo { const type = mostRecentRowType(rows); if (type === messageTypes.TEXT) { const row = assertSingleRow(rows); const rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; const localID = localIDFromCreationString(viewer, row.creation); if (localID) { rawTextMessageInfo.localID = localID; } return rawTextMessageInfo; } else if (type === messageTypes.CREATE_THREAD) { const row = assertSingleRow(rows); const dbInitialThreadState = JSON.parse(row.content); // For legacy clients before the rename const initialThreadState = { ...dbInitialThreadState, visibilityRules: dbInitialThreadState.type, }; return { type: messageTypes.CREATE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialThreadState, }; } else if (type === messageTypes.ADD_MEMBERS) { const row = assertSingleRow(rows); return { type: messageTypes.ADD_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), addedUserIDs: JSON.parse(row.content), }; } else if (type === messageTypes.CREATE_SUB_THREAD) { const row = assertSingleRow(rows); const subthreadPermissions = row.subthread_permissions; if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), childThreadID: row.content, }; } else if (type === messageTypes.CHANGE_SETTINGS) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); const field = Object.keys(content)[0]; return { type: messageTypes.CHANGE_SETTINGS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), field, value: content[field], }; } else if (type === messageTypes.REMOVE_MEMBERS) { const row = assertSingleRow(rows); return { type: messageTypes.REMOVE_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), removedUserIDs: JSON.parse(row.content), }; } else if (type === messageTypes.CHANGE_ROLE) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.CHANGE_ROLE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), userIDs: content.userIDs, newRole: content.newRole, }; } else if (type === messageTypes.LEAVE_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.LEAVE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; } else if (type === messageTypes.JOIN_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.JOIN_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; } else if (type === messageTypes.CREATE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.CREATE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.EDIT_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.EDIT_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.DELETE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.DELETE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.RESTORE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.RESTORE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { const media = rows.filter(row => row.uploadID).map(mediaFromRow); const [row] = rows; return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID: localIDFromCreationString(viewer, row.creation), time: row.time, }); } else { invariant(false, `unrecognized messageType ${type}`); } } const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchMessageInfos( viewer: Viewer, criteria: ThreadSelectionCriteria, numberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` SELECT * FROM ( SELECT x.id, x.content, x.time, x.type, x.user AS creatorID, - x.creation, u.username AS creator, x.subthread_permissions, - x.uploadID, x.uploadType, x.uploadSecret, x.uploadExtra, + x.creation, x.subthread_permissions, x.uploadID, x.uploadType, + x.uploadSecret, x.uploadExtra, @num := if( @thread = x.thread, if(@message = x.id, @num, @num + 1), 1 ) AS number, @message := x.id AS messageID, @thread := x.thread AS threadID FROM (SELECT @num := 0, @thread := '', @message := '') init JOIN ( SELECT m.id, m.thread, m.user, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC ) x - LEFT JOIN users u ON u.id = x.user ) y WHERE y.number <= ${numberPerThread} `); const [result] = await dbQuery(query); - const { messages } = parseMessageSQLResult(result, viewer); + const messages = parseMessageSQLResult(result, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (let message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (let [threadID, messageCount] of threadToMessageCount) { // If there are fewer messages returned than the max for a given thread, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageCount < numberPerThread ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.TRUNCATED; } for (let rawMessageInfo of rawMessageInfos) { if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { // If a CREATE_THREAD message for a given thread is in the result set, // then our result set includes all messages in the query range for that // thread truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (let threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus === null || truncationStatus === undefined) { // If nothing was returned for a thread that was explicitly queried for, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else if (truncationStatus === messageTruncationStatus.TRUNCATED) { // If a cursor was specified for a given thread, then the result is // guaranteed to be contiguous with what the client has, and as such the // result should never be TRUNCATED truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, - userInfos: {}, }; } function threadSelectionCriteriaToSQLClause(criteria: ThreadSelectionCriteria) { const conditions = []; if (criteria.joinedThreads === true) { conditions.push(SQL`mm.role != 0`); } if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { conditions.push(SQL`(m.thread = ${threadID} AND m.id < ${cursor})`); } else { conditions.push(SQL`m.thread = ${threadID}`); } } } if (conditions.length === 0) { throw new ServerError('internal_error'); } return mergeOrConditions(conditions); } function threadSelectionCriteriaToInitialTruncationStatuses( criteria: ThreadSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } -async function fetchAllUsers( - rawMessageInfos: $ReadOnlyArray, - userInfos: UserInfos, -): Promise { - const allAddedUserIDs = []; - for (let rawMessageInfo of rawMessageInfos) { - let newUsers = []; - if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { - newUsers = rawMessageInfo.addedUserIDs; - } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { - newUsers = rawMessageInfo.initialThreadState.memberIDs; - } - for (let userID of newUsers) { - if (!userInfos[userID]) { - allAddedUserIDs.push(userID); - } - } - } - if (allAddedUserIDs.length === 0) { - return userInfos; - } - - const newUserInfos = await fetchUserInfos(allAddedUserIDs); - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - return { - ...userInfos, - ...newUserInfos, - }; -} - async function fetchMessageInfosSince( viewer: Viewer, criteria: ThreadSelectionCriteria, currentAsOf: number, maxNumberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = threadSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, - m.creation, u.username AS creator, m.user AS creatorID, - stm.permissions AS subthread_permissions, + m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} - LEFT JOIN users u ON u.id = m.user WHERE m.time > ${currentAsOf} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC `); const [result] = await dbQuery(query); - const { userInfos: allCreatorUserInfos, messages } = parseMessageSQLResult( - result, - viewer, - ); + const messages = parseMessageSQLResult(result, viewer); const rawMessageInfos = []; - const userInfos = {}; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (let message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { // If a CREATE_THREAD message is here, then we have all messages truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } - const { creatorID } = rawMessageInfo; - const userInfo = allCreatorUserInfos[creatorID]; - if (userInfo) { - userInfos[creatorID] = userInfo; - } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } - const allUserInfos = await fetchAllUsers(rawMessageInfos, userInfos); const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, - userInfos: allUserInfos, }; } -async function getMessageFetchResultFromRedisMessages( +function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, -): Promise { +): FetchMessageInfosResult { const truncationStatuses = {}; for (let rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } - const userInfos = await fetchAllUsers(rawMessageInfos, {}); const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, - userInfos, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawMessageInfoFromRows(result, viewer); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawMessageInfoFromRows(result, viewer); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, }; diff --git a/server/src/fetchers/thread-fetchers.js b/server/src/fetchers/thread-fetchers.js index 0ad286424..62dc37d8a 100644 --- a/server/src/fetchers/thread-fetchers.js +++ b/server/src/fetchers/thread-fetchers.js @@ -1,246 +1,228 @@ // @flow import type { RawThreadInfo, ServerThreadInfo, ThreadPermission, ThreadPermissionsBlob, } from 'lib/types/thread-types'; -import type { AccountUserInfo } from 'lib/types/user-types'; import type { Viewer } from '../session/viewer'; import { getAllThreadPermissions, permissionLookup, } from 'lib/permissions/thread-permissions'; import { rawThreadInfoFromServerThreadInfo } from 'lib/shared/thread-utils'; import { dbQuery, SQL, SQLStatement } from '../database'; type FetchServerThreadInfosResult = {| threadInfos: { [id: string]: ServerThreadInfo }, - userInfos: { [id: string]: AccountUserInfo }, |}; async function fetchServerThreadInfos( condition?: SQLStatement, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const query = SQL` SELECT t.id, t.name, t.parent_thread_id, t.color, t.description, t.type, t.creation_time, t.default_role, r.id AS role, r.name AS role_name, r.permissions AS role_permissions, m.user, - m.permissions, m.subscription, m.unread, u.username + m.permissions, m.subscription, m.unread FROM threads t LEFT JOIN ( SELECT thread, id, name, permissions FROM roles UNION SELECT id AS thread, 0 AS id, NULL AS name, NULL AS permissions FROM threads ) r ON r.thread = t.id LEFT JOIN memberships m ON m.role = r.id AND m.thread = t.id - LEFT JOIN users u ON u.id = m.user ` .append(whereClause) .append(SQL`ORDER BY m.user ASC`); const [result] = await dbQuery(query); const threadInfos = {}; - const userInfos = {}; for (let row of result) { const threadID = row.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: row.type, visibilityRules: row.type, name: row.name ? row.name : '', description: row.description ? row.description : '', color: row.color, creationTime: row.creation_time, parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, members: [], roles: {}, }; } const role = row.role.toString(); if (row.role && !threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: row.role_name, permissions: JSON.parse(row.role_permissions), isDefault: role === row.default_role.toString(), }; } if (row.user) { const userID = row.user.toString(); const allPermissions = getAllThreadPermissions(row.permissions, threadID); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: row.role ? role : null, subscription: row.subscription, unread: row.role ? !!row.unread : null, }); - if (row.username) { - userInfos[userID] = { - id: userID, - username: row.username, - }; - } } } - return { threadInfos, userInfos }; + return { threadInfos }; } export type FetchThreadInfosResult = {| threadInfos: { [id: string]: RawThreadInfo }, - userInfos: { [id: string]: AccountUserInfo }, |}; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatement, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const threadInfos = {}; - const userInfos = {}; for (let threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, ); if (threadInfo) { threadInfos[threadID] = threadInfo; - for (let member of threadInfo.members) { - const userInfo = serverResult.userInfos[member.id]; - if (userInfo) { - userInfos[member.id] = userInfo; - } - } } } - return { threadInfos, userInfos }; + return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; for (let row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } async function fetchThreadPermissionsBlob( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT permissions FROM memberships WHERE thread = ${threadID} AND user = ${viewerID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return row.permissions; } async function checkThreadPermission( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { const permissionsBlob = await fetchThreadPermissionsBlob(viewer, threadID); return permissionLookup(permissionsBlob, permission); } async function checkThreadPermissions( viewer: Viewer, threadIDs: $ReadOnlyArray, permission: ThreadPermission, ): Promise<{ [threadID: string]: boolean }> { const viewerID = viewer.id; const query = SQL` SELECT thread, permissions FROM memberships WHERE thread IN (${threadIDs}) AND user = ${viewerID} `; const [result] = await dbQuery(query); const permissionsBlobs = new Map(); for (let row of result) { const threadID = row.thread.toString(); permissionsBlobs.set(threadID, row.permissions); } const permissionByThread = {}; for (let threadID of threadIDs) { const permissionsBlob = permissionsBlobs.get(threadID); permissionByThread[threadID] = permissionLookup( permissionsBlob, permission, ); } return permissionByThread; } async function viewerIsMember( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT role FROM memberships WHERE user = ${viewerID} AND thread = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return false; } const row = result[0]; return !!row.role; } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, fetchThreadPermissionsBlob, checkThreadPermission, checkThreadPermissions, viewerIsMember, }; diff --git a/server/src/push/send.js b/server/src/push/send.js index 7e9841968..e9665fdc7 100644 --- a/server/src/push/send.js +++ b/server/src/push/send.js @@ -1,699 +1,673 @@ // @flow import { type RawMessageInfo, type MessageInfo, messageTypes, } from 'lib/types/message-types'; -import type { UserInfos } from 'lib/types/user-types'; import type { ServerThreadInfo, ThreadInfo } from 'lib/types/thread-types'; import type { DeviceType } from 'lib/types/device-types'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers'; import { updateTypes } from 'lib/types/update-types'; import apn from 'apn'; import invariant from 'invariant'; import uuidv4 from 'uuid/v4'; import _flow from 'lodash/fp/flow'; import _mapValues from 'lodash/fp/mapValues'; import _pickBy from 'lodash/fp/pickBy'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils'; import { createMessageInfo, sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL, mergeOrConditions } from '../database'; import { apnPush, fcmPush, getUnreadCounts } from './utils'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers'; import { fetchUserInfos } from '../fetchers/user-fetchers'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers'; import createIDs from '../creators/id-creator'; import { createUpdates } from '../creators/update-creator'; type Device = {| deviceType: DeviceType, deviceToken: string, codeVersion: ?number, |}; type PushUserInfo = {| devices: Device[], messageInfos: RawMessageInfo[], |}; type Delivery = IOSDelivery | AndroidDelivery | {| collapsedInto: string |}; type NotificationRow = {| dbID: string, userID: string, threadID: string, messageID: string, collapseKey: ?string, deliveries: Delivery[], |}; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const deliveryPromises = []; const notifications: Map = new Map(); for (let userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, ); if (!rawThreadInfo) { return null; } return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (let notifInfo of usersToCollapsableNotifInfo[userID]) { const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (let newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { continue; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [ firstNewMessageInfo, ...remainingNewMessageInfos ] = newMessageInfos; const threadID = firstNewMessageInfo.threadID; const threadInfo = threadInfos[threadID]; const badgeOnly = !threadInfo.currentUser.subscription.pushNotifs; if (badgeOnly && !threadInfo.currentUser.subscription.home) { continue; } const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byDeviceType = getDevicesByDeviceType(pushInfo[userID].devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (let [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'ios', codeVersion }, ); const notification = prepareIOSNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], ); deliveryPromises.push( sendIOSNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }), ); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (let [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'android', codeVersion }, ); const notification = prepareAndroidNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], dbID, codeVersion, ); deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }), ); } } for (let newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); notifications.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } } } const cleanUpPromises = []; if (dbIDs.length > 0) { const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; cleanUpPromises.push(dbQuery(query)); } const [deliveryResults] = await Promise.all([ Promise.all(deliveryPromises), Promise.all(cleanUpPromises), ]); const allInvalidTokens = []; for (let deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = notifications.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { const { threadID, messageID, collapseKey } = info; notifications.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (let notification of notifications.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), 0, ]); } const dbPromises = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { - const collapsableNotifsResult = await fetchCollapsableNotifs(pushInfo); + const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); if ( rawMessageInfo.type === messageTypes.CREATE_THREAD && rawMessageInfo.initialThreadState.parentThreadID ) { threadIDs.add(rawMessageInfo.initialThreadState.parentThreadID); } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { threadIDs.add(rawMessageInfo.childThreadID); } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; - const usersToCollapsableNotifInfo = - collapsableNotifsResult.usersToCollapsableNotifInfo; for (let userID in usersToCollapsableNotifInfo) { for (let notifInfo of usersToCollapsableNotifInfo[userID]) { for (let rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (let rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } const promises = {}; promises.threadResult = fetchServerThreadInfos( SQL`t.id IN (${[...threadIDs]})`, ); if (threadWithChangedNamesToMessages.size > 0) { const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (let [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); promises.oldNames = dbQuery(oldNameQuery); } - const { threadResult, oldNames } = await promiseAll(promises); + promises.userInfos = fetchMissingUserInfos(usersToCollapsableNotifInfo); + const { threadResult, oldNames, userInfos } = await promiseAll(promises); // These threadInfos won't have currentUser set - const { - threadInfos: serverThreadInfos, - userInfos: threadUserInfos, - } = threadResult; - const mergedUserInfos = { - ...collapsableNotifsResult.userInfos, - ...threadUserInfos, - }; + const { threadInfos: serverThreadInfos } = threadResult; if (oldNames) { const [result] = oldNames; for (let row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID].name = row.name; } } - const userInfos = await fetchMissingUserInfos( - mergedUserInfos, - usersToCollapsableNotifInfo, - ); - return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } -async function fetchMissingUserInfos( - userInfos: UserInfos, - usersToCollapsableNotifInfo: { [userID: string]: CollapsableNotifInfo[] }, -) { +async function fetchMissingUserInfos(usersToCollapsableNotifInfo: { + [userID: string]: CollapsableNotifInfo[], +}) { const missingUserIDs = new Set(); - const addIfMissing = (userID: string) => { - if (!userInfos[userID]) { - missingUserIDs.add(userID); - } - }; const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { - addIfMissing(rawMessageInfo.creatorID); + missingUserIDs.add(rawMessageInfo.creatorID); if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { for (let userID of rawMessageInfo.addedUserIDs) { - addIfMissing(userID); + missingUserIDs.add(userID); } } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { for (let userID of rawMessageInfo.removedUserIDs) { - addIfMissing(userID); + missingUserIDs.add(userID); } } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { for (let userID of rawMessageInfo.initialThreadState.memberIDs) { - addIfMissing(userID); + missingUserIDs.add(userID); } } }; for (let userID in usersToCollapsableNotifInfo) { for (let notifInfo of usersToCollapsableNotifInfo[userID]) { for (let rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (let rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } - let finalUserInfos = userInfos; - if (missingUserIDs.size > 0) { - const newUserInfos = await fetchUserInfos([...missingUserIDs]); - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - finalUserInfos = { ...userInfos, ...newUserInfos }; - } - return finalUserInfos; + return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (let userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } function getDevicesByDeviceType( devices: Device[], ): Map>> { const byDeviceType = new Map(); for (let device of devices) { let innerMap = byDeviceType.get(device.deviceType); if (!innerMap) { innerMap = new Map(); byDeviceType.set(device.deviceType, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; let innerMostSet = innerMap.get(codeVersion); if (!innerMostSet) { innerMostSet = new Set(); innerMap.set(codeVersion, innerMostSet); } innerMostSet.add(device.deviceToken); } return byDeviceType; } function prepareIOSNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, ): apn.Notification { const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = 'org.squadcal.app'; if (!badgeOnly) { const { merged, ...rest } = notifTextsForMessageInfo( allMessageInfos, threadInfo, ); notification.body = merged; notification.sound = 'default'; notification.payload = { ...notification.payload, ...rest, }; } notification.badge = unreadCount; notification.threadId = threadInfo.id; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadInfo.id; notification.payload.messageInfos = JSON.stringify(newRawMessageInfos); if (collapseKey) { notification.collapseId = collapseKey; } return notification; } function prepareAndroidNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, dbID: string, codeVersion: number, ): Object { const notifID = collapseKey ? collapseKey : dbID; if (badgeOnly) { return { data: { badge: unreadCount.toString(), messageInfos: JSON.stringify(newRawMessageInfos), notifID, }, }; } const { merged, ...rest } = notifTextsForMessageInfo( allMessageInfos, threadInfo, ); const messageInfos = JSON.stringify(newRawMessageInfos); if (codeVersion < 31) { return { data: { badge: unreadCount.toString(), custom_notification: JSON.stringify({ channel: 'default', body: merged, badgeCount: unreadCount, id: notifID, priority: 'high', sound: 'default', icon: 'notif_icon', threadID: threadInfo.id, messageInfos, click_action: 'fcm.ACTION.HELLO', }), }, }; } return { data: { badge: unreadCount.toString(), ...rest, id: notifID, threadID: threadInfo.id, messageInfos, }, }; } type NotificationInfo = {| dbID: string, userID: string, threadID: string, messageID: string, collapseKey: ?string, codeVersion: number, |}; type IOSDelivery = {| deviceType: 'ios', iosID: string, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, |}; type IOSResult = {| info: NotificationInfo, delivery: IOSDelivery, invalidTokens?: $ReadOnlyArray, |}; async function sendIOSNotification( notification: apn.Notification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const response = await apnPush(notification, deviceTokens); const delivery: IOSDelivery = { deviceType: 'ios', iosID: notification.id, deviceTokens, codeVersion: notificationInfo.codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: IOSResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type AndroidDelivery = {| deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, |}; type AndroidResult = {| info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, |}; async function sendAndroidNotification( notification: Object, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const response = await fcmPush( notification, deviceTokens, notificationInfo.collapseKey, ); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { deviceType: 'android', androidIDs, deviceTokens, codeVersion: notificationInfo.codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type InvalidToken = {| userID: string, tokens: $ReadOnlyArray, |}; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( invalidTokenUser => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map(); for (let row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises = []; for (let entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map(deviceToken => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } export { sendPushNotifs }; diff --git a/server/src/responders/entry-responders.js b/server/src/responders/entry-responders.js index 96f020e73..ec919e6b2 100644 --- a/server/src/responders/entry-responders.js +++ b/server/src/responders/entry-responders.js @@ -1,258 +1,259 @@ // @flow import type { Viewer } from '../session/viewer'; import type { CalendarQuery, SaveEntryRequest, CreateEntryRequest, DeleteEntryRequest, DeleteEntryResponse, RestoreEntryRequest, RestoreEntryResponse, FetchEntryInfosResponse, DeltaEntryInfosResult, SaveEntryResponse, } from 'lib/types/entry-types'; import type { FetchEntryRevisionInfosResult, FetchEntryRevisionInfosRequest, } from 'lib/types/history-types'; import { calendarThreadFilterTypes } from 'lib/types/filter-types'; import t from 'tcomb'; import { ServerError } from 'lib/utils/errors'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; import { validateInput, tString, tShape, tDate, } from '../utils/validation-utils'; import { verifyThreadIDs } from '../fetchers/thread-fetchers'; import { fetchEntryInfos, fetchEntryRevisionInfo, fetchEntriesForSession, } from '../fetchers/entry-fetchers'; import createEntry from '../creators/entry-creator'; import { updateEntry, compareNewCalendarQuery, } from '../updaters/entry-updaters'; import { deleteEntry, restoreEntry } from '../deleters/entry-deleters'; import { commitSessionUpdate } from '../updaters/session-updaters'; const entryQueryInputValidator = 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(t.String), }), ]), ), ), }); const newEntryQueryInputValidator = 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(t.String), }), ]), ), }); 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, input: any, ): Promise { await validateInput(viewer, entryQueryInputValidator, input); const request = normalizeCalendarQuery(input); await verifyCalendarQueryThreadIDs(request); - return await fetchEntryInfos(viewer, [request]); + const response = await fetchEntryInfos(viewer, [request]); + return { ...response, userInfos: {} }; } const entryRevisionHistoryFetchInputValidator = tShape({ id: t.String, }); async function entryRevisionFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchEntryRevisionInfosRequest = input; await validateInput(viewer, entryRevisionHistoryFetchInputValidator, request); const entryHistory = await fetchEntryRevisionInfo(viewer, request.id); return { result: entryHistory }; } const createEntryRequestInputValidator = tShape({ text: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, date: tDate, threadID: t.String, localID: t.maybe(t.String), calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryCreationResponder( viewer: Viewer, input: any, ): Promise { const request: CreateEntryRequest = input; await validateInput(viewer, createEntryRequestInputValidator, request); return await createEntry(viewer, request); } const saveEntryRequestInputValidator = tShape({ entryID: t.String, text: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SaveEntryRequest = input; await validateInput(viewer, saveEntryRequestInputValidator, request); return await updateEntry(viewer, request); } const deleteEntryRequestInputValidator = tShape({ entryID: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteEntryRequest = input; await validateInput(viewer, deleteEntryRequestInputValidator, request); return await deleteEntry(viewer, request); } const restoreEntryRequestInputValidator = tShape({ entryID: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryRestorationResponder( viewer: Viewer, input: any, ): Promise { const request: RestoreEntryRequest = input; await validateInput(viewer, restoreEntryRequestInputValidator, request); return await restoreEntry(viewer, request); } async function calendarQueryUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: CalendarQuery = input; await validateInput(viewer, newEntryQueryInputValidator, input); 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/server/src/responders/message-responders.js b/server/src/responders/message-responders.js index 2df215cdc..b5426f3dc 100644 --- a/server/src/responders/message-responders.js +++ b/server/src/responders/message-responders.js @@ -1,146 +1,146 @@ // @flow import type { Viewer } from '../session/viewer'; import { messageTypes, type SendTextMessageRequest, type SendMultimediaMessageRequest, - type FetchMessageInfosResult, + type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, type TextMessageData, } from 'lib/types/message-types'; import t from 'tcomb'; import invariant from 'invariant'; import { ServerError } from 'lib/utils/errors'; import { threadPermissions } from 'lib/types/thread-types'; import { createMediaMessageData, trimMessage } from 'lib/shared/message-utils'; import createMessages from '../creators/message-creator'; import { validateInput, tShape } from '../utils/validation-utils'; import { checkThreadPermission } from '../fetchers/thread-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchMedia } from '../fetchers/upload-fetchers'; import { assignMedia } from '../updaters/upload-updaters'; const sendTextMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.maybe(t.String), text: t.String, }); async function textMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendTextMessageRequest = input; await validateInput(viewer, sendTextMessageRequestInputValidator, request); const { threadID, localID, text: rawText } = request; const text = trimMessage(rawText); if (!text) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const messageData: TextMessageData = { type: messageTypes.TEXT, threadID, creatorID: viewer.id, time: Date.now(), text, }; if (localID) { messageData.localID = localID; } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } const fetchMessageInfosRequestInputValidator = tShape({ cursors: t.dict(t.String, t.maybe(t.String)), numberPerThread: t.maybe(t.Number), }); - async function messageFetchResponder( viewer: Viewer, input: any, -): Promise { +): Promise { const request: FetchMessageInfosRequest = input; await validateInput(viewer, fetchMessageInfosRequestInputValidator, request); - return await fetchMessageInfos( + const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); + return { ...response, userInfos: {} }; } const sendMultimediaMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.String, mediaIDs: t.list(t.String), }); async function multimediaMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendMultimediaMessageRequest = input; await validateInput( viewer, sendMultimediaMessageRequestInputValidator, request, ); const { threadID, localID, mediaIDs } = request; if (mediaIDs.length === 0) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const media = await fetchMedia(viewer, mediaIDs); if (media.length !== mediaIDs.length) { throw new ServerError('invalid_parameters'); } const messageData = createMediaMessageData({ localID, threadID, creatorID: viewer.id, media, }); const [newMessageInfo] = await createMessages(viewer, [messageData]); const { id } = newMessageInfo; invariant( id !== null && id !== undefined, 'serverID should be set in createMessages result', ); await assignMedia(viewer, mediaIDs, id); return { newMessageInfo }; } export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, }; diff --git a/server/src/responders/user-responders.js b/server/src/responders/user-responders.js index 0ffee006c..c215a8400 100644 --- a/server/src/responders/user-responders.js +++ b/server/src/responders/user-responders.js @@ -1,325 +1,325 @@ // @flow import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types'; import type { AccountUpdate } from 'lib/types/user-types'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, AccessRequest, } from 'lib/types/account-types'; import type { Viewer } from '../session/viewer'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; import invariant from 'invariant'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { values } from 'lib/utils/objects'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, } from '../updaters/account-updaters'; import { validateInput, tShape, tPlatformDetails, tDeviceType, tPassword, } from '../utils/validation-utils'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies'; import { deleteCookie } from '../deleters/cookie-deleters'; import { deleteAccount } from '../deleters/account-deleters'; import createAccount from '../creators/account-creator'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; import { dbQuery, SQL } from '../database'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { sendAccessRequestEmailToAshoat } from '../emails/access-request'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; +import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(t.String), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function accountUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: AccountUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.String, }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: tPassword, }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.String, password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } const logInRequestInputValidator = tShape({ usernameOrEmail: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } const userQuery = SQL` SELECT id, hash, username, email, email_verified FROM users WHERE LCASE(username) = LCASE(${request.usernameOrEmail}) OR LCASE(email) = LCASE(${request.usernameOrEmail}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { 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 newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (let watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; - const [threadsResult, messagesResult, entriesResult] = await Promise.all([ + const [ + threadsResult, + messagesResult, + entriesResult, + userInfos, + ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, + fetchKnownUserInfos(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; - const entryUserInfos = entriesResult ? entriesResult.userInfos : {}; - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const userInfos = values({ - ...threadsResult.userInfos, - ...messagesResult.userInfos, - ...entryUserInfos, - }); const response: LogInResponse = { currentUserInfo: { id, username: userRow.username, email: userRow.email, emailVerified: !!userRow.email_verified, }, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, - userInfos, + userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const accessRequestInputValidator = tShape({ email: t.String, platform: tDeviceType, }); async function requestAccessResponder( viewer: Viewer, input: any, ): Promise { const request: AccessRequest = input; await validateInput(viewer, accessRequestInputValidator, request); await sendAccessRequestEmailToAshoat(request); } export { userSubscriptionUpdateResponder, accountUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, passwordUpdateResponder, requestAccessResponder, }; diff --git a/server/src/socket/session-utils.js b/server/src/socket/session-utils.js index 1c95961e2..2b1ab66ff 100644 --- a/server/src/socket/session-utils.js +++ b/server/src/socket/session-utils.js @@ -1,522 +1,505 @@ // @flow 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 } 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 { promiseAll } from 'lib/utils/promises'; import { hash } from 'lib/utils/objects'; import { usersInRawEntryInfos, serverEntryInfo, serverEntryInfosObject, } from 'lib/shared/entry-utils'; import { usersInThreadInfo } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { tShape, tPlatform, tPlatformDetails } from '../utils/validation-utils'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchEntryInfos, fetchEntryInfosByID, fetchEntriesForSession, } from '../fetchers/entry-fetchers'; import { activityUpdater } from '../updaters/activity-updaters'; import { fetchCurrentUserInfo, fetchUserInfos, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { setNewSession, setCookiePlatform, setCookiePlatformDetails, } from '../session/cookies'; import createReport from '../creators/report-creator'; import { compareNewCalendarQuery } from '../updaters/entry-updaters'; import { activityUpdatesInputValidator } from '../responders/activity-responders'; import { SQL } from '../database'; import { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, } from '../responders/report-responders'; const clientResponseInputValidator = t.union([ tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM', x => x === serverRequestTypes.PLATFORM, ), platform: tPlatform, }), 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.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, }), ]); 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)); const promises = []; let activityUpdates = []; let stateCheckStatus = null; 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.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_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 }); } 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; sessionUpdate.lastUpdate = oldLastUpdate; const deltaEntryInfoResult = await fetchEntriesForSession( viewer, difference, oldCalendarQuery, ); return { sessionContinued: true, deltaEntryInfoResult, sessionUpdate }; } else { await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } } type StateCheckResult = {| sessionUpdate?: SessionUpdate, checkStateRequest?: CheckStateServerRequest, |}; async function checkState( viewer: Viewer, status: StateCheckStatus, calendarQuery: CalendarQuery, ): Promise { const shouldCheckUserInfos = hasMinCodeVersion(viewer.platformDetails, 59); if (status.status === 'state_validated') { return { sessionUpdate: { lastValidated: Date.now() } }; } else if (status.status === 'state_check') { const promises = { threadsResult: fetchThreadInfos(viewer), entriesResult: fetchEntryInfos(viewer, [calendarQuery]), currentUserInfo: fetchCurrentUserInfo(viewer), 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 = [], 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 = fetchKnownUserInfos(viewer, 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); } } 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); } } - 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 { - oldUserIDsToFetch.push(userID); - } - } - if (oldUserIDsToFetch.length > 0) { - const fetchedUserInfos = await fetchUserInfos(oldUserIDsToFetch); + if (userIDs.size > 0) { + const fetchedUserInfos = await fetchUserInfos([...userIDs]); 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; } } 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, processClientResponses, initializeSession, checkState, }; diff --git a/server/src/socket/socket.js b/server/src/socket/socket.js index f5028c1cf..c6a4efa2c 100644 --- a/server/src/socket/socket.js +++ b/server/src/socket/socket.js @@ -1,790 +1,790 @@ // @flow import type { WebSocket } from 'ws'; import type { $Request } from 'express'; import { type ClientSocketMessage, type InitialClientSocketMessage, type ResponsesClientSocketMessage, type StateSyncFullSocketPayload, type ServerSocketMessage, type ErrorServerSocketMessage, type AuthErrorServerSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, clientSocketMessageTypes, stateSyncPayloadTypes, serverSocketMessageTypes, } from 'lib/types/socket-types'; import { cookieSources, sessionCheckFrequency, stateCheckInactivityActivationInterval, } from 'lib/types/session-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { redisMessageTypes, type RedisMessage } from 'lib/types/redis-types'; import { endpointIsSocketSafe } from 'lib/types/endpoints'; import t from 'tcomb'; import invariant from 'invariant'; import _debounce from 'lodash/debounce'; 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 } from 'lib/utils/objects'; import { serverRequestSocketTimeout } from 'lib/shared/timeouts'; import { Viewer } from '../session/viewer'; import { checkInputValidator, checkClientSupported, tShape, tCookie, } from '../utils/validation-utils'; import { newEntryQueryInputValidator, verifyCalendarQueryThreadIDs, } from '../responders/entry-responders'; import { clientResponseInputValidator, processClientResponses, initializeSession, checkState, } from './session-utils'; import { assertSecureRequest } from '../utils/security-utils'; import { fetchViewerForSocket, extendCookieLifespan, createNewAnonymousCookie, } from '../session/cookies'; import { fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { updateActivityTime } from '../updaters/activity-updaters'; import { deleteUpdatesBeforeTimeTargettingSession } from '../deleters/update-deleters'; import { fetchUpdateInfos } from '../fetchers/update-fetchers'; import { commitSessionUpdate } from '../updaters/session-updaters'; import { handleAsyncPromise } from '../responders/handlers'; import { deleteCookie } from '../deleters/cookie-deleters'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters'; import { RedisSubscriber } from './redis'; import { fetchUpdateInfosWithRawUpdateInfos } from '../creators/update-creator'; import { jsonEndpoints } from '../endpoints'; const clientSocketMessageInputValidator = 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, 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.Object, }), }), ]); function onConnection(ws: WebSocket, req: $Request) { assertSecureRequest(req); new Socket(ws, req); } type StateCheckConditions = {| activityRecentlyOccurred: boolean, stateCheckOngoing: boolean, |}; class Socket { ws: WebSocket; httpRequest: $Request; viewer: ?Viewer; redis: ?RedisSubscriber; 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(); } onMessage = async (messageString: string) => { let clientSocketMessage: ?ClientSocketMessage; try { this.resetTimeout(); const message = JSON.parse(messageString); checkInputValidator(clientSocketMessageInputValidator, message); clientSocketMessage = message; if (clientSocketMessage.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, clientSocketMessage, ); if (!this.viewer) { // This indicates that the cookie was invalid, but the client is using // cookieSources.HEADER and thus can't accept a new cookie over // WebSockets. See comment under catch block for socket_deauthorized. throw new ServerError('socket_deauthorized'); } } 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, clientSocketMessage, ); if (!this.redis) { this.redis = new RedisSubscriber( { userID: viewer.userID, sessionID: viewer.session }, this.onRedisMessage, ); } const serverResponses = await this.handleClientSocketMessage( clientSocketMessage, ); 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'); } handleAsyncPromise(extendCookieLifespan(viewer.cookieID)); for (let response of serverResponses) { 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, }; const responseTo = clientSocketMessage ? clientSocketMessage.id : null; if (responseTo !== null) { errorMessage.responseTo = responseTo; } this.markActivityOccurred(); this.sendMessage(errorMessage); return; } invariant(clientSocketMessage, 'should be set'); const responseTo = clientSocketMessage.id; if (error.message === 'socket_deauthorized') { const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (this.viewer) { // viewer should only be falsey for cookieSources.HEADER (web) // clients. Usually if the cookie is invalid we construct a new // anonymous Viewer with a new cookie, and then pass the cookie down // in the error. But we can't pass HTTP cookies in WebSocket messages. authErrorMessage.sessionChange = { cookie: this.viewer.cookiePairString, currentUserInfo: { id: this.viewer.cookieID, anonymous: true, }, }; } 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 promises = {}; promises.deleteCookie = deleteCookie(viewer.cookieID); if (viewer.cookieSource !== cookieSources.BODY) { promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails: error.platformDetails, deviceToken: viewer.deviceToken, }); } const { anonymousViewerData } = await promiseAll(promises); const authErrorMessage: AuthErrorServerSocketMessage = { type: serverSocketMessageTypes.AUTH_ERROR, responseTo, message: error.message, }; if (anonymousViewerData) { // 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); authErrorMessage.sessionChange = { cookie: anonViewer.cookiePairString, currentUserInfo: { id: anonViewer.cookieID, anonymous: true, }, }; } this.sendMessage(authErrorMessage); this.ws.close(4101, error.message); return; } if (error.payload) { this.sendMessage({ type: serverSocketMessageTypes.ERROR, responseTo, message: error.message, payload: error.payload, }); } else { 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(message: ServerSocketMessage) { invariant( this.ws.readyState > 0, "shouldn't send message until connection established", ); if (this.ws.readyState === 1) { this.ws.send(JSON.stringify(message)); } } async handleClientSocketMessage( message: ClientSocketMessage, ): Promise { 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 await 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 []; } async handleInitialClientSocketMessage( message: InitialClientSocketMessage, ): Promise { const { viewer } = this; invariant(viewer, 'should be set'); const responses = []; 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 = {}; for (let watchedThreadID of watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; const [ fetchMessagesResult, { serverRequests, activityUpdateResult }, ] = await Promise.all([ fetchMessageInfosSince( viewer, threadSelectionCriteria, oldMessagesCurrentAsOf, defaultNumberPerThread, ), processClientResponses(viewer, clientResponses), ]); const messagesResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, currentAsOf: mostRecentMessageTimestamp( fetchMessagesResult.rawMessageInfos, oldMessagesCurrentAsOf, ), }; if (!sessionInitializationResult.sessionContinued) { const [ threadsResult, entriesResult, currentUserInfo, knownUserInfos, ] = await Promise.all([ fetchThreadInfos(viewer), fetchEntryInfos(viewer, [calendarQuery]), fetchCurrentUserInfo(viewer), fetchKnownUserInfos(viewer), ]); const payload: StateSyncFullSocketPayload = { type: stateSyncPayloadTypes.FULL, messagesResult, threadInfos: threadsResult.threadInfos, currentUserInfo, rawEntryInfos: entriesResult.rawEntryInfos, userInfos: values(knownUserInfos), 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 promises = {}; promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargettingSession( viewer, oldUpdatesCurrentAsOf, ); promises.fetchUpdateResult = fetchUpdateInfos( viewer, oldUpdatesCurrentAsOf, calendarQuery, ); promises.sessionUpdate = commitSessionUpdate(viewer, sessionUpdate); const { fetchUpdateResult } = await promiseAll(promises); 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), }, }); } 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, viewer.calendarQuery, ); 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 }, }, ]; } async handlePingClientSocketMessage( message: PingClientSocketMessage, ): Promise { this.updateActivityTime(); 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([ deleteUpdatesBeforeTimeTargettingSession(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]; const response = await 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; const { updateInfos, userInfos, } = await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, { viewer }); if (updateInfos.length === 0) { console.warn( 'could not get any UpdateInfos from redisMessageTypes.NEW_UPDATES', ); return; } this.markActivityOccurred(); this.sendMessage({ 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 = await getMessageFetchResultFromRedisMessages( + const messageFetchResult = getMessageFetchResultFromRedisMessages( viewer, rawMessageInfos, ); if (messageFetchResult.rawMessageInfos.length === 0) { console.warn( 'could not get any rawMessageInfos from ' + 'redisMessageTypes.NEW_MESSAGES', ); return; } this.markActivityOccurred(); this.sendMessage({ 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(); } updateActivityTime() { const { viewer } = this; invariant(viewer, 'should be set'); handleAsyncPromise(updateActivityTime(viewer)); } // The Socket will timeout by calling this.ws.terminate() // serverRequestSocketTimeout milliseconds after the last // time resetTimeout is called resetTimeout = _debounce( () => this.ws.terminate(), serverRequestSocketTimeout, ); debouncedAfterActivity = _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: $Shape) { this.stateCheckConditions = { ...this.stateCheckConditions, ...newConditions, }; this.handleStateCheckConditionsUpdate(); } get stateCheckCanStart() { 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) { 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' }, viewer.calendarQuery, ); invariant(checkStateRequest, 'should be set'); this.sendMessage({ type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [checkStateRequest] }, }); }; } export { onConnection }; diff --git a/server/src/updaters/account-updaters.js b/server/src/updaters/account-updaters.js index a4f7b44d4..f1b571920 100644 --- a/server/src/updaters/account-updaters.js +++ b/server/src/updaters/account-updaters.js @@ -1,270 +1,272 @@ // @flow import type { AccountUpdate } from 'lib/types/user-types'; import type { Viewer } from '../session/viewer'; import type { ResetPasswordRequest, LogInResponse, UpdatePasswordRequest, } from 'lib/types/account-types'; import { updateTypes } from 'lib/types/update-types'; import bcrypt from 'twin-bcrypt'; import { validEmailRegex } from 'lib/shared/account-utils'; import { promiseAll } from 'lib/utils/promises'; import { ServerError } from 'lib/utils/errors'; import { verifyField } from 'lib/types/verify-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { values } from 'lib/utils/objects'; import { dbQuery, SQL } from '../database'; import { sendEmailAddressVerificationEmail } from '../emails/verification'; import { sendPasswordResetEmail } from '../emails/reset-password'; import { verifyCode, clearVerifyCodes } from '../models/verification'; import { createNewUserCookie, setNewSession } from '../session/cookies'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; -import { fetchLoggedInUserInfos } from '../fetchers/user-fetchers'; +import { + fetchLoggedInUserInfos, + fetchKnownUserInfos, +} from '../fetchers/user-fetchers'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders'; import { createUpdates } from '../creators/update-creator'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; async function accountUpdater( viewer: Viewer, update: AccountUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const email = update.updatedFields.email; const newPassword = update.updatedFields.password; const fetchPromises = {}; fetchPromises.currentUserInfos = fetchLoggedInUserInfos([viewer.userID]); if (email) { if (email.search(validEmailRegex) === -1) { throw new ServerError('invalid_email'); } fetchPromises.emailQuery = dbQuery(SQL` SELECT COUNT(id) AS count FROM users WHERE email = ${email} `); } fetchPromises.verifyQuery = dbQuery(SQL` SELECT username, email, hash FROM users WHERE id = ${viewer.userID} `); const { verifyQuery, emailQuery, currentUserInfos } = await promiseAll( fetchPromises, ); const [verifyResult] = verifyQuery; if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } if (currentUserInfos.length === 0) { throw new ServerError('internal_error'); } const currentUserInfo = currentUserInfos[0]; const savePromises = []; const changedFields = {}; let currentUserInfoChanged = false; if (email && email !== verifyRow.email) { const [emailResult] = emailQuery; const emailRow = emailResult[0]; if (emailRow.count !== 0) { throw new ServerError('email_taken'); } changedFields.email = email; changedFields.email_verified = 0; currentUserInfoChanged = true; currentUserInfo.email = email; currentUserInfo.emailVerified = false; savePromises.push( sendEmailAddressVerificationEmail( viewer.userID, verifyRow.username, email, ), ); } if (newPassword) { changedFields.hash = bcrypt.hashSync(newPassword); } if (Object.keys(changedFields).length > 0) { savePromises.push( dbQuery(SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `), ); } await Promise.all(savePromises); if (currentUserInfoChanged) { const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); } } async function checkAndSendVerificationEmail(viewer: Viewer): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const query = SQL` SELECT username, email, email_verified FROM users WHERE id = ${viewer.userID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; if (row.email_verified) { throw new ServerError('already_verified'); } await sendEmailAddressVerificationEmail( viewer.userID, row.username, row.email, ); } async function checkAndSendPasswordResetEmail(request: ResetPasswordRequest) { const query = SQL` SELECT id, username, email FROM users WHERE LCASE(username) = LCASE(${request.usernameOrEmail}) OR LCASE(email) = LCASE(${request.usernameOrEmail}) `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_user'); } const row = result[0]; await sendPasswordResetEmail(row.id.toString(), row.username, row.email); } async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } const calendarQuery = request.calendarQuery; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } promises.verificationResult = verifyCode(request.code); const { verificationResult } = await promiseAll(promises); const { userID, field } = verificationResult; if (field !== verifyField.RESET_PASSWORD) { throw new ServerError('invalid_code'); } const userQuery = SQL` SELECT username, email, email_verified FROM users WHERE id = ${userID} `; const hash = bcrypt.hashSync(request.password); const updateQuery = SQL`UPDATE users SET hash = ${hash} WHERE id = ${userID}`; const [[userResult]] = await Promise.all([ dbQuery(userQuery), dbQuery(updateQuery), ]); if (userResult.length === 0) { throw new ServerError('invalid_parameters'); } const userRow = userResult[0]; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, }), clearVerifyCodes(verificationResult), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (let watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const threadSelectionCriteria = { threadCursors, joinedThreads: true }; - const [threadsResult, messagesResult, entriesResult] = await Promise.all([ + const [ + threadsResult, + messagesResult, + entriesResult, + userInfos, + ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, + fetchKnownUserInfos(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; - const entryUserInfos = entriesResult ? entriesResult.userInfos : {}; - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const userInfos = values({ - ...threadsResult.userInfos, - ...messagesResult.userInfos, - ...entryUserInfos, - }); const response: LogInResponse = { currentUserInfo: { id: userID, username: userRow.username, email: userRow.email, emailVerified: !!userRow.email_verified, }, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, - userInfos, + userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } export { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, }; diff --git a/server/src/updaters/thread-permission-updaters.js b/server/src/updaters/thread-permission-updaters.js index 35471ba14..910f6d28e 100644 --- a/server/src/updaters/thread-permission-updaters.js +++ b/server/src/updaters/thread-permission-updaters.js @@ -1,735 +1,738 @@ // @flow import { type ThreadPermissionsBlob, type ThreadRolePermissionsBlob, type ThreadType, assertThreadType, } from 'lib/types/thread-types'; import type { ThreadSubscription } from 'lib/types/subscription-types'; import type { Viewer } from '../session/viewer'; import { updateTypes, type UpdateInfo } from 'lib/types/update-types'; import type { CalendarQuery } from 'lib/types/entry-types'; +import type { AccountUserInfo } from 'lib/types/user-types'; import { type UndirectedRelationshipRow, undirectedStatus, } from 'lib/types/relationship-types'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import _uniqWith from 'lodash/fp/uniqWith'; import { makePermissionsBlob, makePermissionsForChildrenBlob, } from 'lib/permissions/thread-permissions'; import { sortIDs } from 'lib/shared/relationship-utils'; import { ServerError } from 'lib/utils/errors'; import { cartesianProduct } from 'lib/utils/array'; import { fetchServerThreadInfos, rawThreadInfosFromServerThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers'; import { createUpdates } from '../creators/update-creator'; import { updateDatasForUserPairs, updateUndirectedRelationships, } from '../updaters/relationship-updaters'; import { rescindPushNotifs } from '../push/rescind'; import { dbQuery, SQL, mergeOrConditions } from '../database'; type MembershipRowToSave = {| operation: 'update' | 'join', userID: string, threadID: string, permissions: ThreadPermissionsBlob, permissionsForChildren: ?ThreadPermissionsBlob, // null role represents by "0" role: string, subscription?: ThreadSubscription, unread?: boolean, |}; type MembershipRowToDelete = {| operation: 'delete', userID: string, threadID: string, |}; type MembershipRow = MembershipRowToSave | MembershipRowToDelete; type Changeset = {| membershipRows: MembershipRow[], relationshipRows: UndirectedRelationshipRow[], |}; // 0 role means to remove the user from the thread // null role means to set the user to the default role // string role means to set the user to the role with that ID async function changeRole( threadID: string, userIDs: $ReadOnlyArray, role: string | 0 | null, ): Promise { const membershipQuery = SQL` SELECT m.user, m.role, m.permissions_for_children, pm.permissions_for_children AS permissions_from_parent FROM memberships m LEFT JOIN threads t ON t.id = m.thread LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user WHERE m.thread = ${threadID} `; const [[membershipResult], roleThreadResult] = await Promise.all([ dbQuery(membershipQuery), changeRoleThreadQuery(threadID, role), ]); if (!roleThreadResult) { return null; } const roleInfo = new Map(); for (let row of membershipResult) { const userID = row.user.toString(); const oldPermissionsForChildren = row.permissions_for_children; const permissionsFromParent = row.permissions_from_parent; roleInfo.set(userID, { oldRole: row.role.toString(), oldPermissionsForChildren, permissionsFromParent, }); } const relationshipRows = []; const membershipRows = []; const toUpdateDescendants = new Map(); const memberIDs = new Set(roleInfo.keys()); for (let userID of userIDs) { let oldPermissionsForChildren = null; let permissionsFromParent = null; let hadMembershipRow = false; const userRoleInfo = roleInfo.get(userID); if (userRoleInfo) { const oldRole = userRoleInfo.oldRole; if (oldRole === roleThreadResult.roleColumnValue) { // If the old role is the same as the new one, we have nothing to update continue; } else if (Number(oldRole) > 0 && role === null) { // In the case where we're just trying to add somebody to a thread, if // they already have a role with a nonzero role then we don't need to do // anything continue; } oldPermissionsForChildren = userRoleInfo.oldPermissionsForChildren; permissionsFromParent = userRoleInfo.permissionsFromParent; hadMembershipRow = true; } const permissions = makePermissionsBlob( roleThreadResult.rolePermissions, permissionsFromParent, threadID, roleThreadResult.threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); if (permissions) { membershipRows.push({ operation: roleThreadResult.roleColumnValue !== '0' && (!userRoleInfo || Number(userRoleInfo.oldRole) <= 0) ? 'join' : 'update', userID, threadID, permissions, permissionsForChildren, role: roleThreadResult.roleColumnValue, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !hadMembershipRow) { for (const currentUserID of memberIDs) { if (userID !== currentUserID) { const [user1, user2] = sortIDs(userID, currentUserID); relationshipRows.push({ user1, user2, status: undirectedStatus.KNOW_OF, }); } } memberIDs.add(userID); } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { toUpdateDescendants.set(userID, permissionsForChildren); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipRows: descendantRelationshipRows, } = await updateDescendantPermissions(threadID, toUpdateDescendants); membershipRows.push(...descendantMembershipRows); relationshipRows.push(...descendantRelationshipRows); } return { membershipRows, relationshipRows }; } type RoleThreadResult = {| roleColumnValue: string, threadType: ThreadType, rolePermissions: ?ThreadRolePermissionsBlob, |}; async function changeRoleThreadQuery( threadID: string, role: string | 0 | null, ): Promise { if (role === 0) { const query = SQL`SELECT type FROM threads WHERE id = ${threadID}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: '0', threadType: assertThreadType(row.type), rolePermissions: null, }; } else if (role !== null) { const query = SQL` SELECT t.type, r.permissions FROM threads t LEFT JOIN roles r ON r.id = ${role} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: role, threadType: assertThreadType(row.type), rolePermissions: row.permissions, }; } else { const query = SQL` SELECT t.type, t.default_role, r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: row.default_role.toString(), threadType: assertThreadType(row.type), rolePermissions: row.permissions, }; } } async function updateDescendantPermissions( initialParentThreadID: string, initialUsersToPermissionsFromParent: Map, ): Promise { const stack = [[initialParentThreadID, initialUsersToPermissionsFromParent]]; const membershipRows = []; const relationshipRows = []; while (stack.length > 0) { const [parentThreadID, usersToPermissionsFromParent] = stack.shift(); const query = SQL` SELECT t.id, m.user, t.type, r.permissions AS role_permissions, m.permissions, m.permissions_for_children, m.role FROM threads t LEFT JOIN memberships m ON m.thread = t.id LEFT JOIN roles r ON r.id = m.role WHERE t.parent_thread_id = ${parentThreadID} `; const [result] = await dbQuery(query); const childThreadInfos = new Map(); for (let row of result) { const threadID = row.id.toString(); if (!childThreadInfos.has(threadID)) { childThreadInfos.set(threadID, { threadType: assertThreadType(row.type), userInfos: new Map(), }); } if (!row.user) { continue; } const childThreadInfo = childThreadInfos.get(threadID); invariant(childThreadInfo, `value should exist for key ${threadID}`); const userID = row.user.toString(); childThreadInfo.userInfos.set(userID, { role: row.role.toString(), rolePermissions: row.role_permissions, permissions: row.permissions, permissionsForChildren: row.permissions_for_children, }); } for (let [threadID, childThreadInfo] of childThreadInfos) { const userInfos = childThreadInfo.userInfos; const usersForNextLayer = new Map(); for (const [ userID, permissionsFromParent, ] of usersToPermissionsFromParent) { const userInfo = userInfos.get(userID); const role = userInfo ? userInfo.role : '0'; const rolePermissions = userInfo ? userInfo.rolePermissions : null; const oldPermissions = userInfo ? userInfo.permissions : null; const oldPermissionsForChildren = userInfo ? userInfo.permissionsForChildren : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, childThreadInfo.threadType, ); if (_isEqual(permissions)(oldPermissions)) { // This thread and all of its children need no updates, since its // permissions are unchanged by this operation continue; } const permissionsForChildren = makePermissionsForChildrenBlob( permissions, ); if (permissions) { membershipRows.push({ operation: 'update', userID, threadID, permissions, permissionsForChildren, role, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !oldPermissions) { for (const [childUserID] of userInfos) { if (childUserID !== userID) { const [user1, user2] = sortIDs(childUserID, userID); const status = undirectedStatus.KNOW_OF; relationshipRows.push({ user1, user2, status }); } } } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { usersForNextLayer.set(userID, permissionsForChildren); } } if (usersForNextLayer.size > 0) { stack.push([threadID, usersForNextLayer]); } } } return { membershipRows, relationshipRows }; } async function recalculateAllPermissions( threadID: string, threadType: ThreadType, ): Promise { const selectQuery = SQL` SELECT m.user, m.role, m.permissions, m.permissions_for_children, pm.permissions_for_children AS permissions_from_parent, r.permissions AS role_permissions FROM memberships m LEFT JOIN threads t ON t.id = m.thread LEFT JOIN roles r ON r.id = m.role LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user WHERE m.thread = ${threadID} UNION SELECT pm.user, 0 AS role, NULL AS permissions, NULL AS permissions_for_children, pm.permissions_for_children AS permissions_from_parent, NULL AS role_permissions FROM threads t LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id LEFT JOIN memberships m ON m.thread = t.id AND m.user = pm.user WHERE t.id = ${threadID} AND m.thread IS NULL `; const [selectResult] = await dbQuery(selectQuery); const relationshipRows = []; const membershipRows = []; const toUpdateDescendants = new Map(); const parentIDs = new Set(); const childIDs = selectResult.reduce((acc, row) => { if (row.user && row.role !== 0) { acc.push(row.user.toString()); return acc; } return acc; }, []); for (let row of selectResult) { if (!row.user) { continue; } const userID = row.user.toString(); const role = row.role.toString(); const oldPermissions = JSON.parse(row.permissions); const oldPermissionsForChildren = JSON.parse(row.permissions_for_children); const permissionsFromParent = JSON.parse(row.permissions_from_parent); const rolePermissions = JSON.parse(row.role_permissions); const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); if (_isEqual(permissions)(oldPermissions)) { // This thread and all of its children need no updates, since its // permissions are unchanged by this operation continue; } const permissionsForChildren = makePermissionsForChildrenBlob(permissions); if (permissions) { membershipRows.push({ operation: 'update', userID, threadID, permissions, permissionsForChildren, role, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !oldPermissions) { parentIDs.add(userID); for (const childID of childIDs) { const [user1, user2] = sortIDs(userID, childID); relationshipRows.push({ user1, user2, status: undirectedStatus.KNOW_OF, }); } } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { toUpdateDescendants.set(userID, permissionsForChildren); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipRows: descendantRelationshipRows, } = await updateDescendantPermissions(threadID, toUpdateDescendants); membershipRows.push(...descendantMembershipRows); relationshipRows.push( ...descendantRelationshipRows.filter(({ user1, user2 }) => { return ( parentIDs.has(user1.toString()) || parentIDs.has(user2.toString()) ); }), ); } return { membershipRows, relationshipRows }; } const defaultSubscriptionString = JSON.stringify({ home: false, pushNotifs: false, }); const joinSubscriptionString = JSON.stringify({ home: true, pushNotifs: true }); async function saveMemberships(toSave: $ReadOnlyArray) { if (toSave.length === 0) { return; } const time = Date.now(); const insertRows = []; for (let rowToSave of toSave) { let subscription; if (rowToSave.subscription) { subscription = JSON.stringify(rowToSave.subscription); } else if (rowToSave.operation === 'join') { subscription = joinSubscriptionString; } else { subscription = defaultSubscriptionString; } insertRows.push([ rowToSave.userID, rowToSave.threadID, rowToSave.role, time, subscription, JSON.stringify(rowToSave.permissions), rowToSave.permissionsForChildren ? JSON.stringify(rowToSave.permissionsForChildren) : null, rowToSave.unread ? '1' : '0', ]); } // Logic below will only update an existing membership row's `subscription` // column if the user is either joining or leaving the thread. That means // there's no way to use this function to update a user's subscription without // also making them join or leave the thread. The reason we do this is because // we need to specify a value for `subscription` here, as it's a non-null // column and this is an INSERT, but we don't want to require people to have // to know the current `subscription` when they're just using this function to // update the permissions of an existing membership row. const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, unread) VALUES ${insertRows} ON DUPLICATE KEY UPDATE subscription = IF( (role <= 0 AND VALUES(role) > 0) OR (role > 0 AND VALUES(role) <= 0), VALUES(subscription), subscription ), role = VALUES(role), permissions = VALUES(permissions), permissions_for_children = VALUES(permissions_for_children) `; await dbQuery(query); } async function deleteMemberships( toDelete: $ReadOnlyArray, ) { if (toDelete.length === 0) { return; } const deleteRows = toDelete.map( rowToDelete => SQL`(user = ${rowToDelete.userID} AND thread = ${rowToDelete.threadID})`, ); const conditions = mergeOrConditions(deleteRows); const query = SQL`UPDATE memberships SET role = -1 WHERE `; query.append(conditions); await dbQuery(query); } // Specify non-empty changedThreadIDs to force updates to be generated for those // threads, presumably for reasons not covered in the changeset. calendarQuery // only needs to be specified if a JOIN_THREAD update will be generated for the // viewer, in which case it's necessary for knowing the set of entries to fetch. type ChangesetCommitResult = {| ...FetchThreadInfosResult, viewerUpdates: $ReadOnlyArray, + userInfos: { [id: string]: AccountUserInfo }, |}; async function commitMembershipChangeset( viewer: Viewer, changeset: Changeset, changedThreadIDs?: Set = new Set(), calendarQuery?: ?CalendarQuery, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { membershipRows, relationshipRows } = changeset; const membershipRowMap = new Map(); for (let row of membershipRows) { const { userID, threadID } = row; changedThreadIDs.add(threadID); const pairString = `${userID}|${threadID}`; const existing = membershipRowMap.get(pairString); if ( !existing || (existing.operation !== 'join' && (row.operation === 'join' || (row.operation === 'delete' && existing.operation === 'update'))) ) { membershipRowMap.set(pairString, row); } } const toSave = [], toDelete = [], rescindPromises = []; for (let row of membershipRowMap.values()) { if ( row.operation === 'delete' || (row.operation === 'update' && Number(row.role) <= 0) ) { const { userID, threadID } = row; rescindPromises.push( rescindPushNotifs( SQL`n.thread = ${threadID} AND n.user = ${userID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ), ); } if (row.operation === 'delete') { toDelete.push(row); } else { toSave.push(row); } } const uniqueRelationshipRows = _uniqWith(_isEqual)(relationshipRows); await Promise.all([ saveMemberships(toSave), deleteMemberships(toDelete), updateUndirectedRelationships(uniqueRelationshipRows), ...rescindPromises, ]); // We fetch all threads here because old clients still expect the full list of // threads on most thread operations. Once verifyClientSupported gates on // codeVersion 62, we can add a WHERE clause on changedThreadIDs here const serverThreadInfoFetchResult = await fetchServerThreadInfos(); const { threadInfos: serverThreadInfos } = serverThreadInfoFetchResult; const time = Date.now(); const updateDatas = updateDatasForUserPairs( uniqueRelationshipRows.map(({ user1, user2 }) => [user1, user2]), ); for (let changedThreadID of changedThreadIDs) { const serverThreadInfo = serverThreadInfos[changedThreadID]; for (let memberInfo of serverThreadInfo.members) { const pairString = `${memberInfo.id}|${serverThreadInfo.id}`; const membershipRow = membershipRowMap.get(pairString); if (membershipRow && membershipRow.operation !== 'update') { continue; } updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: memberInfo.id, time, threadID: changedThreadID, }); } } for (let row of membershipRowMap.values()) { const { userID, threadID } = row; if (row.operation === 'join') { updateDatas.push({ type: updateTypes.JOIN_THREAD, userID, time, threadID, }); } else if (row.operation === 'delete') { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID, time, threadID, }); } } const threadInfoFetchResult = rawThreadInfosFromServerThreadInfos( viewer, serverThreadInfoFetchResult, ); - const { viewerUpdates } = await createUpdates(updateDatas, { + const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, ...threadInfoFetchResult, updatesForCurrentSession: 'return', }); return { ...threadInfoFetchResult, + userInfos, viewerUpdates, }; } function setJoinsToUnread( rows: MembershipRow[], exceptViewerID: string, exceptThreadID: string, ) { for (let row of rows) { if ( row.operation === 'join' && (row.userID !== exceptViewerID || row.threadID !== exceptThreadID) ) { row.unread = true; } } } function getRelationshipRowsForUsers( viewerID: string, userIDs: $ReadOnlyArray, ): UndirectedRelationshipRow[] { return cartesianProduct([viewerID], userIDs).map(pair => { const [user1, user2] = sortIDs(...pair); const status = undirectedStatus.KNOW_OF; return { user1, user2, status }; }); } function getParentThreadRelationshipRowsForNewUsers( threadID: string, recalculateMembershipRows: MembershipRow[], newMemberIDs: $ReadOnlyArray, ): UndirectedRelationshipRow[] { const parentMemberIDs = recalculateMembershipRows .map(rowToSave => rowToSave.userID) .filter(userID => !newMemberIDs.includes(userID)); const newUserIDs = newMemberIDs.filter( memberID => !recalculateMembershipRows.find( rowToSave => rowToSave.userID === memberID && rowToSave.threadID === threadID && rowToSave.operation !== 'delete', ), ); return cartesianProduct(parentMemberIDs, newUserIDs).map(pair => { const [user1, user2] = sortIDs(...pair); const status = undirectedStatus.KNOW_OF; return { user1, user2, status }; }); } export { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, }; diff --git a/server/src/updaters/thread-updaters.js b/server/src/updaters/thread-updaters.js index bc3c63318..635658953 100644 --- a/server/src/updaters/thread-updaters.js +++ b/server/src/updaters/thread-updaters.js @@ -1,641 +1,628 @@ // @flow import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, threadPermissions, threadTypes, assertThreadType, } from 'lib/types/thread-types'; import type { Viewer } from '../session/viewer'; import { messageTypes, defaultNumberPerThread } from 'lib/types/message-types'; import bcrypt from 'twin-bcrypt'; import _find from 'lodash/fp/find'; import invariant from 'invariant'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { dbQuery, SQL } from '../database'; import { verifyUserIDs, verifyUserOrCookieIDs, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { checkThreadPermission, fetchServerThreadInfos, viewerIsMember, fetchThreadPermissionsBlob, } from '../fetchers/thread-fetchers'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getParentThreadRelationshipRowsForNewUsers, } from './thread-permission-updaters'; import createMessages from '../creators/message-creator'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { updateRoles } from './role-updaters'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (let row of result) { if (!row.role) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); if (!changeset) { throw new ServerError('unknown_error'); } const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const [newMessageInfos, { threadInfos, viewerUpdates }] = await Promise.all([ createMessages(viewer, [messageData]), commitMembershipChangeset(viewer, changeset), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (let row of result) { if (!row.role) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); if (!changeset) { throw new ServerError('unknown_error'); } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; const [newMessageInfos, { threadInfos, viewerUpdates }] = await Promise.all([ createMessages(viewer, [messageData]), commitMembershipChangeset(viewer, changeset), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, { threadInfos: serverThreadInfos }] = await Promise.all([ viewerIsMember(viewer, request.threadID), fetchServerThreadInfos(SQL`t.id = ${request.threadID}`), ]); if (!isMember) { throw new ServerError('invalid_parameters'); } const serverThreadInfo = serverThreadInfos[request.threadID]; const viewerID = viewer.userID; if (_find({ name: 'Admins' })(serverThreadInfo.roles)) { let otherUsersExist = false; let otherAdminsExist = false; for (let member of serverThreadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (serverThreadInfo.roles[role].name === 'Admins') { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); if (!changeset) { throw new ServerError('unknown_error'); } const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; const [{ threadInfos, viewerUpdates }] = await Promise.all([ commitMembershipChangeset(viewer, changeset), createMessages(viewer, [messageData]), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } async function updateThread( viewer: Viewer, request: UpdateThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const name = request.changes.name; if (name !== undefined && name !== null) { changedFields.name = request.changes.name; sqlUpdate.name = request.changes.name ? request.changes.name : null; } const description = request.changes.description; if (description !== undefined && description !== null) { changedFields.description = request.changes.description; sqlUpdate.description = request.changes.description ? request.changes.description : null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const parentThreadID = request.changes.parentThreadID; if (parentThreadID !== undefined) { if (parentThreadID !== null) { validationPromises.canMoveThread = checkThreadPermission( viewer, parentThreadID, threadPermissions.CREATE_SUBTHREADS, ); } // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...request.changes.newMemberIDs] : null; if (newMemberIDs) { validationPromises.fetchNewMembers = fetchKnownUserInfos( viewer, newMemberIDs, ); } validationPromises.threadPermissionsBlob = fetchThreadPermissionsBlob( viewer, request.threadID, ); // Two unrelated purposes for this query: // - get hash for viewer password check (users table) // - get current value of type, parent_thread_id (threads table) const validationQuery = SQL` SELECT u.hash, t.type, t.parent_thread_id FROM users u LEFT JOIN threads t ON t.id = ${request.threadID} WHERE u.id = ${viewer.userID} `; validationPromises.validationQuery = dbQuery(validationQuery); const { canMoveThread, threadPermissionsBlob, fetchNewMembers, validationQuery: [validationResult], } = await promiseAll(validationPromises); if (canMoveThread === false) { throw new ServerError('invalid_credentials'); } if (fetchNewMembers) { invariant(newMemberIDs, 'should be set'); for (const newMemberID of newMemberIDs) { if (!fetchNewMembers[newMemberID]) { throw new ServerError('invalid_credentials'); } } } if (Object.keys(sqlUpdate).length === 0 && !newMemberIDs) { throw new ServerError('invalid_parameters'); } if (validationResult.length === 0 || validationResult[0].type === null) { throw new ServerError('internal_error'); } const validationRow = validationResult[0]; if (sqlUpdate.name || sqlUpdate.description || sqlUpdate.color) { const canEditThread = permissionLookup( threadPermissionsBlob, threadPermissions.EDIT_THREAD, ); if (!canEditThread) { throw new ServerError('invalid_credentials'); } } if (sqlUpdate.parent_thread_id || sqlUpdate.type) { const canEditPermissions = permissionLookup( threadPermissionsBlob, threadPermissions.EDIT_PERMISSIONS, ); if (!canEditPermissions) { throw new ServerError('invalid_credentials'); } if ( !request.accountPassword || !bcrypt.compareSync(request.accountPassword, validationRow.hash) ) { throw new ServerError('invalid_credentials'); } } if (newMemberIDs) { const canAddMembers = permissionLookup( threadPermissionsBlob, threadPermissions.ADD_MEMBERS, ); if (!canAddMembers) { throw new ServerError('invalid_credentials'); } } const oldThreadType = assertThreadType(validationRow.type); const oldParentThreadID = validationRow.parentThreadID ? validationRow.parentThreadID.toString() : null; // If the thread is being switched to nested, a parent must be specified if ( oldThreadType === threadTypes.CHAT_SECRET && threadType !== threadTypes.CHAT_SECRET && oldParentThreadID === null && parentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; const nextParentThreadID = parentThreadID ? parentThreadID : oldParentThreadID; const intermediatePromises = {}; if (Object.keys(sqlUpdate).length > 0) { const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; intermediatePromises.updateQuery = dbQuery(updateQuery); } if (newMemberIDs) { intermediatePromises.addMembersChangeset = changeRole( request.threadID, newMemberIDs, null, ); } if ( nextThreadType !== oldThreadType || nextParentThreadID !== oldParentThreadID ) { intermediatePromises.recalculatePermissionsChangeset = (async () => { if (nextThreadType !== oldThreadType) { await updateRoles(viewer, request.threadID, nextThreadType); } return await recalculateAllPermissions(request.threadID, nextThreadType); })(); } const { addMembersChangeset, recalculatePermissionsChangeset, } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipRows = []; if (recalculatePermissionsChangeset && newMemberIDs) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( request.threadID, recalculateMembershipRows, newMemberIDs, ); relationshipRows.push( ...recalculateRelationshipRows, ...parentRelationshipRows, ); } else if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipRows.push(...recalculateRelationshipRows); } if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipRows: addMembersRelationshipRows, } = addMembersChangeset; relationshipRows.push(...addMembersRelationshipRows); setJoinsToUnread(addMembersMembershipRows, viewer.userID, request.threadID); membershipRows.push(...addMembersMembershipRows); } const time = Date.now(); const messageDatas = []; for (let fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (newMemberIDs) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: newMemberIDs, }); } const changeset = { membershipRows, relationshipRows }; const [newMessageInfos, { threadInfos, viewerUpdates }] = await Promise.all([ createMessages(viewer, messageDatas), commitMembershipChangeset( viewer, changeset, // This forces an update for this thread, // regardless of whether any membership rows are changed Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), ), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, hasPermission] = await Promise.all([ viewerIsMember(viewer, request.threadID), checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ), ]); if (isMember || !hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); if (!changeset) { throw new ServerError('unknown_error'); } setJoinsToUnread(changeset.membershipRows, viewer.userID, request.threadID); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const [membershipResult] = await Promise.all([ commitMembershipChangeset(viewer, changeset, new Set(), calendarQuery), createMessages(viewer, [messageData]), ]); const threadSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - let userInfos = { - ...fetchMessagesResult.userInfos, - ...membershipResult.userInfos, - }; - let rawEntryInfos; - if (fetchEntriesResult) { - userInfos = { - ...userInfos, - ...fetchEntriesResult.userInfos, - }; - rawEntryInfos = fetchEntriesResult.rawEntryInfos; - } - + const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, - userInfos, + userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (!hasMinCodeVersion(viewer.platformDetails, 62)) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread };