diff --git a/keyserver/src/responders/activity-responders.js b/keyserver/src/responders/activity-responders.js index 1c2ec4083..4be2cb8ee 100644 --- a/keyserver/src/responders/activity-responders.js +++ b/keyserver/src/responders/activity-responders.js @@ -1,66 +1,66 @@ // @flow import t from 'tcomb'; import type { TList } from 'tcomb'; import { type UpdateActivityResult, type UpdateActivityRequest, type SetThreadUnreadStatusRequest, type SetThreadUnreadStatusResult, type ActivityUpdate, setThreadUnreadStatusResult, updateActivityResultValidator, } from 'lib/types/activity-types.js'; -import { tShape } from 'lib/utils/validation-utils.js'; +import { tShape, tID } from 'lib/utils/validation-utils.js'; import type { Viewer } from '../session/viewer.js'; import { activityUpdater, setThreadUnreadStatus, } from '../updaters/activity-updaters.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; const activityUpdatesInputValidator: TList> = t.list( tShape({ focus: t.Bool, - threadID: t.String, - latestMessage: t.maybe(t.String), + threadID: tID, + latestMessage: t.maybe(tID), }), ); const inputValidator = tShape({ updates: activityUpdatesInputValidator, }); async function updateActivityResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateActivityRequest = input; await validateInput(viewer, inputValidator, request); const result = await activityUpdater(viewer, request); return validateOutput(viewer, updateActivityResultValidator, result); } -const setThreadUnreadStatusValidator = tShape({ - threadID: t.String, +const setThreadUnreadStatusValidator = tShape({ + threadID: tID, unread: t.Bool, - latestMessage: t.maybe(t.String), + latestMessage: t.maybe(tID), }); async function threadSetUnreadStatusResponder( viewer: Viewer, input: any, ): Promise { const request: SetThreadUnreadStatusRequest = input; await validateInput(viewer, setThreadUnreadStatusValidator, request); const result = await setThreadUnreadStatus(viewer, request); return validateOutput(viewer, setThreadUnreadStatusResult, result); } export { activityUpdatesInputValidator, updateActivityResponder, threadSetUnreadStatusResponder, }; diff --git a/keyserver/src/responders/entry-responders.js b/keyserver/src/responders/entry-responders.js index 377eab8e2..0d43e8227 100644 --- a/keyserver/src/responders/entry-responders.js +++ b/keyserver/src/responders/entry-responders.js @@ -1,318 +1,318 @@ // @flow import t from 'tcomb'; import type { TInterface } from 'tcomb'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { type CalendarQuery, type SaveEntryRequest, type CreateEntryRequest, type DeleteEntryRequest, type DeleteEntryResponse, type RestoreEntryRequest, type RestoreEntryResponse, type FetchEntryInfosResponse, type DeltaEntryInfosResult, type SaveEntryResponse, rawEntryInfoValidator, } from 'lib/types/entry-types.js'; import { type CalendarFilter, calendarThreadFilterTypes, } from 'lib/types/filter-types.js'; import { type FetchEntryRevisionInfosResult, type FetchEntryRevisionInfosRequest, historyRevisionInfoValidator, } from 'lib/types/history-types.js'; import { rawMessageInfoValidator } from 'lib/types/message-types.js'; import { serverCreateUpdatesResponseValidator } from 'lib/types/update-types.js'; import { accountUserInfoValidator } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tString, tShape, tDate, tID } from 'lib/utils/validation-utils.js'; import createEntry from '../creators/entry-creator.js'; import { deleteEntry, restoreEntry } from '../deleters/entry-deleters.js'; import { fetchEntryInfos, fetchEntryRevisionInfo, fetchEntriesForSession, } from '../fetchers/entry-fetchers.js'; import { verifyThreadIDs } from '../fetchers/thread-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { updateEntry, compareNewCalendarQuery, } from '../updaters/entry-updaters.js'; import { commitSessionUpdate } from '../updaters/session-updaters.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; type EntryQueryInput = { +startDate: string, +endDate: string, +navID?: ?string, +includeDeleted?: ?boolean, +filters?: ?$ReadOnlyArray, }; const entryQueryInputValidator: TInterface = tShape({ navID: t.maybe(t.String), startDate: tDate, endDate: tDate, includeDeleted: t.maybe(t.Boolean), filters: t.maybe( t.list( t.union([ tShape({ type: tString(calendarThreadFilterTypes.NOT_DELETED), }), tShape({ type: tString(calendarThreadFilterTypes.THREAD_LIST), - threadIDs: t.list(t.String), + threadIDs: t.list(tID), }), ]), ), ), }); const newEntryQueryInputValidator: TInterface = tShape({ startDate: tDate, endDate: tDate, filters: t.list( t.union([ tShape({ type: tString(calendarThreadFilterTypes.NOT_DELETED), }), tShape({ type: tString(calendarThreadFilterTypes.THREAD_LIST), - threadIDs: t.list(t.String), + threadIDs: t.list(tID), }), ]), ), }); function normalizeCalendarQuery(input: any): CalendarQuery { if (input.filters) { return { startDate: input.startDate, endDate: input.endDate, filters: input.filters, }; } const filters = []; if (!input.includeDeleted) { filters.push({ type: calendarThreadFilterTypes.NOT_DELETED }); } if (input.navID !== 'home') { filters.push({ type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: [input.navID], }); } return { startDate: input.startDate, endDate: input.endDate, filters, }; } async function verifyCalendarQueryThreadIDs( request: CalendarQuery, ): Promise { const threadIDsToFilterTo = filteredThreadIDs(request.filters); if (threadIDsToFilterTo && threadIDsToFilterTo.size > 0) { const verifiedThreadIDs = await verifyThreadIDs([...threadIDsToFilterTo]); if (verifiedThreadIDs.length !== threadIDsToFilterTo.size) { throw new ServerError('invalid_parameters'); } } } export const fetchEntryInfosResponseValidator: TInterface = tShape({ rawEntryInfos: t.list(rawEntryInfoValidator), userInfos: t.dict(t.String, accountUserInfoValidator), }); async function entryFetchResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, entryQueryInputValidator, input); const request = normalizeCalendarQuery(input); await verifyCalendarQueryThreadIDs(request); const response = await fetchEntryInfos(viewer, [request]); return validateOutput(viewer, fetchEntryInfosResponseValidator, { ...response, userInfos: {}, }); } const entryRevisionHistoryFetchInputValidator = tShape({ - id: t.String, + id: tID, }); export const fetchEntryRevisionInfosResultValidator: TInterface = tShape({ result: t.list(historyRevisionInfoValidator), }); async function entryRevisionFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchEntryRevisionInfosRequest = input; await validateInput(viewer, entryRevisionHistoryFetchInputValidator, request); const entryHistory = await fetchEntryRevisionInfo(viewer, request.id); const response = { result: entryHistory }; return validateOutput( viewer, fetchEntryRevisionInfosResultValidator, response, ); } const createEntryRequestInputValidator = tShape({ text: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, date: tDate, - threadID: t.String, + threadID: tID, localID: t.maybe(t.String), calendarQuery: t.maybe(newEntryQueryInputValidator), }); export const saveEntryResponseValidator: TInterface = tShape({ entryID: tID, newMessageInfos: t.list(rawMessageInfoValidator), updatesResult: serverCreateUpdatesResponseValidator, }); async function entryCreationResponder( viewer: Viewer, input: any, ): Promise { const request: CreateEntryRequest = input; await validateInput(viewer, createEntryRequestInputValidator, request); const response = await createEntry(viewer, request); return validateOutput(viewer, saveEntryResponseValidator, response); } const saveEntryRequestInputValidator = tShape({ - entryID: t.String, + entryID: tID, 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); const response = await updateEntry(viewer, request); return validateOutput(viewer, saveEntryResponseValidator, response); } const deleteEntryRequestInputValidator = tShape({ - entryID: t.String, + entryID: tID, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); export const deleteEntryResponseValidator: TInterface = tShape({ newMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, updatesResult: serverCreateUpdatesResponseValidator, }); async function entryDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteEntryRequest = input; await validateInput(viewer, deleteEntryRequestInputValidator, request); const response = await deleteEntry(viewer, request); return validateOutput(viewer, deleteEntryResponseValidator, response); } const restoreEntryRequestInputValidator = tShape({ - entryID: t.String, + entryID: tID, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); export const restoreEntryResponseValidator: TInterface = tShape({ newMessageInfos: t.list(rawMessageInfoValidator), updatesResult: serverCreateUpdatesResponseValidator, }); async function entryRestorationResponder( viewer: Viewer, input: any, ): Promise { const request: RestoreEntryRequest = input; await validateInput(viewer, restoreEntryRequestInputValidator, request); const response = await restoreEntry(viewer, request); return validateOutput(viewer, restoreEntryResponseValidator, response); } export const deltaEntryInfosResultValidator: TInterface = tShape({ rawEntryInfos: t.list(rawEntryInfoValidator), deletedEntryIDs: t.list(tID), userInfos: t.list(accountUserInfoValidator), }); 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 validateOutput(viewer, deltaEntryInfosResultValidator, { rawEntryInfos: response.rawEntryInfos, deletedEntryIDs: response.deletedEntryIDs, // Old clients expect userInfos object userInfos: [], }); } export { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, }; diff --git a/keyserver/src/responders/message-report-responder.js b/keyserver/src/responders/message-report-responder.js index 6844fd4c2..6986d6cfb 100644 --- a/keyserver/src/responders/message-report-responder.js +++ b/keyserver/src/responders/message-report-responder.js @@ -1,39 +1,39 @@ // @flow -import t, { type TInterface } from 'tcomb'; +import type { TInterface } from 'tcomb'; import { type MessageReportCreationRequest, type MessageReportCreationResult, } from 'lib/types/message-report-types.js'; import { rawMessageInfoValidator } from 'lib/types/message-types.js'; -import { tShape } from 'lib/utils/validation-utils.js'; +import { tShape, tID } from 'lib/utils/validation-utils.js'; import createMessageReport from '../creators/message-report-creator.js'; import type { Viewer } from '../session/viewer.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; const messageReportCreationRequestInputValidator = tShape({ - messageID: t.String, + messageID: tID, }); export const messageReportCreationResultValidator: TInterface = tShape({ messageInfo: rawMessageInfoValidator }); async function messageReportCreationResponder( viewer: Viewer, input: any, ): Promise { await validateInput( viewer, messageReportCreationRequestInputValidator, input, ); const request: MessageReportCreationRequest = input; const rawMessageInfos = await createMessageReport(viewer, request); const result = { messageInfo: rawMessageInfos[0] }; return validateOutput(viewer, messageReportCreationResultValidator, result); } export { messageReportCreationResponder }; diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js index 74fe1fdca..05754e148 100644 --- a/keyserver/src/responders/message-responders.js +++ b/keyserver/src/responders/message-responders.js @@ -1,446 +1,447 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { onlyOneEmojiRegex } from 'lib/shared/emojis.js'; import { createMediaMessageData, trimMessage, } from 'lib/shared/message-utils.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import type { Media } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type SendTextMessageRequest, type SendMultimediaMessageRequest, type SendReactionMessageRequest, type SendEditMessageRequest, type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, type SendEditMessageResponse, type FetchPinnedMessagesRequest, type FetchPinnedMessagesResult, messageTruncationStatusesValidator, rawMessageInfoValidator, } from 'lib/types/message-types.js'; import type { EditMessageData } from 'lib/types/messages/edit.js'; import type { ReactionMessageData } from 'lib/types/messages/reaction.js'; import type { TextMessageData } from 'lib/types/messages/text.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { userInfosValidator } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { tRegex, tShape, tMediaMessageMedia, + tID, } from 'lib/utils/validation-utils.js'; import createMessages from '../creators/message-creator.js'; import { SQL } from '../database/database.js'; import { fetchMessageInfos, fetchMessageInfoForLocalID, fetchMessageInfoByID, fetchThreadMessagesCount, fetchPinnedMessageInfos, } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { fetchImages, fetchMediaFromMediaMessageContent, } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { assignImages, assignMessageContainerToMedia, } from '../updaters/upload-updaters.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; const sendTextMessageRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, localID: t.maybe(t.String), text: t.String, sidebarCreation: t.maybe(t.Boolean), }); export const sendMessageResponseValidator: TInterface = tShape({ newMessageInfo: rawMessageInfoValidator }); async function textMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendTextMessageRequest = input; await validateInput(viewer, sendTextMessageRequestInputValidator, request); const { threadID, localID, text: rawText, sidebarCreation } = 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'); } let messageData: TextMessageData = { type: messageTypes.TEXT, threadID, creatorID: viewer.id, time: Date.now(), text, }; if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { const numMessages = await fetchThreadMessagesCount(threadID); if (numMessages === 2) { // sidebarCreation is set below to prevent double notifs from a sidebar // creation. We expect precisely two messages to appear before a // sidebarCreation: a SIDEBAR_SOURCE and a CREATE_SIDEBAR. If two users // attempt to create a sidebar at the same time, then both clients will // attempt to set sidebarCreation here, but we only want to suppress // notifs for the client that won the race. messageData = { ...messageData, sidebarCreation }; } } const rawMessageInfos = await createMessages(viewer, [messageData]); const response = { newMessageInfo: rawMessageInfos[0] }; return validateOutput(viewer, sendMessageResponseValidator, response); } const fetchMessageInfosRequestInputValidator = tShape({ - cursors: t.dict(t.String, t.maybe(t.String)), + cursors: t.dict(tID, t.maybe(tID)), numberPerThread: t.maybe(t.Number), }); export const fetchMessageInfosResponseValidator: TInterface = tShape({ rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, userInfos: userInfosValidator, }); async function messageFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchMessageInfosRequest = input; await validateInput(viewer, fetchMessageInfosRequestInputValidator, request); const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); return validateOutput(viewer, fetchMessageInfosResponseValidator, { ...response, userInfos: {}, }); } const sendMultimediaMessageRequestInputValidator = t.union([ // This option is only used for messageTypes.IMAGES tShape({ - threadID: t.String, + threadID: tID, localID: t.String, sidebarCreation: t.maybe(t.Boolean), - mediaIDs: t.list(t.String), + mediaIDs: t.list(tID), }), tShape({ - threadID: t.String, + threadID: tID, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaMessageContents: t.list(tMediaMessageMedia), }), ]); async function multimediaMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendMultimediaMessageRequest = input; await validateInput( viewer, sendMultimediaMessageRequestInputValidator, request, ); if ( (request.mediaIDs && request.mediaIDs.length === 0) || (request.mediaMessageContents && request.mediaMessageContents.length === 0) ) { throw new ServerError('invalid_parameters'); } const { threadID, localID, sidebarCreation } = request; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const existingMessageInfoPromise = fetchMessageInfoForLocalID( viewer, localID, ); const mediaPromise: Promise<$ReadOnlyArray> = request.mediaIDs ? fetchImages(viewer, request.mediaIDs) : fetchMediaFromMediaMessageContent(viewer, request.mediaMessageContents); const [existingMessageInfo, media] = await Promise.all([ existingMessageInfoPromise, mediaPromise, ]); if (media.length === 0 && !existingMessageInfo) { throw new ServerError('invalid_parameters'); } // We use the MULTIMEDIA type for encrypted photos const containsEncryptedMedia = media.some( m => m.type === 'encrypted_photo' || m.type === 'encrypted_video', ); const messageData = createMediaMessageData( { localID, threadID, creatorID: viewer.id, media, sidebarCreation, }, { forceMultimediaMessageType: containsEncryptedMedia }, ); const [newMessageInfo] = await createMessages(viewer, [messageData]); const { id } = newMessageInfo; invariant( id !== null && id !== undefined, 'serverID should be set in createMessages result', ); if (request.mediaIDs) { await assignImages(viewer, request.mediaIDs, id, threadID); } else { await assignMessageContainerToMedia( viewer, request.mediaMessageContents, id, threadID, ); } const response = { newMessageInfo }; return validateOutput(viewer, sendMessageResponseValidator, response); } const sendReactionMessageRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, localID: t.maybe(t.String), - targetMessageID: t.String, + targetMessageID: tID, reaction: tRegex(onlyOneEmojiRegex), action: t.enums.of(['add_reaction', 'remove_reaction']), }); async function reactionMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendReactionMessageRequest = input; await validateInput(viewer, sendReactionMessageRequestInputValidator, input); const { threadID, localID, targetMessageID, reaction, action } = request; if (!targetMessageID || !reaction) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } const [serverThreadInfos, hasPermission, targetMessageUserInfos] = await Promise.all([ fetchServerThreadInfos(SQL`t.id = ${threadID}`), checkThreadPermission( viewer, threadID, threadPermissions.REACT_TO_MESSAGE, ), fetchKnownUserInfos(viewer, [targetMessageInfo.creatorID]), ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { throw new ServerError('invalid_parameters'); } const targetMessageCreator = targetMessageUserInfos[targetMessageInfo.creatorID]; const targetMessageCreatorRelationship = targetMessageCreator?.relationshipStatus; const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); if (!hasPermission || creatorRelationshipHasBlock) { throw new ServerError('invalid_parameters'); } let messageData: ReactionMessageData = { type: messageTypes.REACTION, threadID, creatorID: viewer.id, time: Date.now(), targetMessageID, reaction, action, }; if (localID) { messageData = { ...messageData, localID }; } const rawMessageInfos = await createMessages(viewer, [messageData]); const response = { newMessageInfo: rawMessageInfos[0] }; return validateOutput(viewer, sendMessageResponseValidator, response); } const editMessageRequestInputValidator = tShape({ - targetMessageID: t.String, + targetMessageID: tID, text: t.String, }); export const sendEditMessageResponseValidator: TInterface = tShape({ newMessageInfos: t.list(rawMessageInfoValidator), }); async function editMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendEditMessageRequest = input; await validateInput(viewer, editMessageRequestInputValidator, input); const { targetMessageID, text: rawText } = request; const text = trimMessage(rawText); if (!targetMessageID || !text) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } if (targetMessageInfo.type !== messageTypes.TEXT) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessageInfo; const [serverThreadInfos, hasPermission, rawSidebarThreadInfos] = await Promise.all([ fetchServerThreadInfos(SQL`t.id = ${threadID}`), checkThreadPermission(viewer, threadID, threadPermissions.EDIT_MESSAGE), fetchServerThreadInfos( SQL`t.parent_thread_id = ${threadID} AND t.source_message = ${targetMessageID}`, ), ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { // We are editing first message of the sidebar // If client wants to do that it sends id of the sourceMessage instead throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_parameters'); } if (targetMessageInfo.creatorID !== viewer.id) { throw new ServerError('invalid_parameters'); } const time = Date.now(); const messagesData = []; let messageData: EditMessageData = { type: messageTypes.EDIT_MESSAGE, threadID, creatorID: viewer.id, time, targetMessageID, text, }; messagesData.push(messageData); const sidebarThreadValues = values(rawSidebarThreadInfos.threadInfos); for (const sidebarThreadValue of sidebarThreadValues) { if (sidebarThreadValue && sidebarThreadValue.id) { messageData = { type: messageTypes.EDIT_MESSAGE, threadID: sidebarThreadValue.id, creatorID: viewer.id, time, targetMessageID, text: text, }; messagesData.push(messageData); } } const newMessageInfos = await createMessages(viewer, messagesData); const response = { newMessageInfos }; return validateOutput(viewer, sendEditMessageResponseValidator, response); } const fetchPinnedMessagesResponderInputValidator = tShape({ - threadID: t.String, + threadID: tID, }); export const fetchPinnedMessagesResultValidator: TInterface = tShape({ pinnedMessages: t.list(rawMessageInfoValidator), }); async function fetchPinnedMessagesResponder( viewer: Viewer, input: any, ): Promise { const request: FetchPinnedMessagesRequest = input; await validateInput( viewer, fetchPinnedMessagesResponderInputValidator, input, ); const response = await fetchPinnedMessageInfos(viewer, request); return validateOutput(viewer, fetchPinnedMessagesResultValidator, response); } export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, fetchPinnedMessagesResponder, }; diff --git a/keyserver/src/responders/responder-validators.test.js b/keyserver/src/responders/responder-validators.test.js index acc54c9e0..fb72172dc 100644 --- a/keyserver/src/responders/responder-validators.test.js +++ b/keyserver/src/responders/responder-validators.test.js @@ -1,920 +1,940 @@ // @flow import { setThreadUnreadStatusResult, updateActivityResultValidator, } from 'lib/types/activity-types.js'; import { fetchEntryInfosResponseValidator, fetchEntryRevisionInfosResultValidator, saveEntryResponseValidator, deleteEntryResponseValidator, deltaEntryInfosResultValidator, restoreEntryResponseValidator, } from './entry-responders.js'; import { getSessionPublicKeysResponseValidator } from './keys-responders.js'; import { messageReportCreationResultValidator } from './message-report-responder.js'; import { fetchMessageInfosResponseValidator, fetchPinnedMessagesResultValidator, sendEditMessageResponseValidator, sendMessageResponseValidator, } from './message-responders.js'; import { relationshipErrorsValidator } from './relationship-responders.js'; import { reportCreationResponseValidator } from './report-responders.js'; import { userSearchResultValidator } from './search-responders.js'; import { siweNonceResponseValidator } from './siwe-nonce-responders.js'; import { changeThreadSettingsResultValidator, leaveThreadResultValidator, newThreadResponseValidator, threadFetchMediaResultValidator, threadJoinResultValidator, toggleMessagePinResultValidator, + roleChangeRequestInputValidator, } from './thread-responders.js'; import { logInResponseValidator, registerResponseValidator, logOutResponseValidator, } from './user-responders.js'; describe('user responder validators', () => { it('should validate logout response', () => { const response = { currentUserInfo: { id: '93078', anonymous: true } }; expect(logOutResponseValidator.is(response)).toBe(true); response.currentUserInfo.anonymous = false; expect(logOutResponseValidator.is(response)).toBe(false); }); it('should validate register response', () => { const response = { id: '93079', rawMessageInfos: [ { type: 1, threadID: '93095', creatorID: '93079', time: 1682086407469, initialThreadState: { type: 6, name: null, parentThreadID: '1', color: '648caa', memberIDs: ['256', '93079'], }, id: '93110', }, { type: 0, threadID: '93095', creatorID: '256', time: 1682086407575, text: 'welcome to Comm!', id: '93113', }, ], currentUserInfo: { id: '93079', username: 'user' }, cookieChange: { threadInfos: { '1': { id: '1', type: 12, name: 'GENESIS', description: 'desc', color: 'c85000', creationTime: 1672934346213, parentThreadID: null, members: [ { id: '256', role: '83796', permissions: { know_of: { value: true, source: '1' }, membership: { value: false, source: null }, visible: { value: true, source: '1' }, voiced: { value: true, source: '1' }, edit_entries: { value: true, source: '1' }, edit_thread: { value: true, source: '1' }, edit_thread_description: { value: true, source: '1' }, edit_thread_color: { value: true, source: '1' }, delete_thread: { value: true, source: '1' }, create_subthreads: { value: true, source: '1' }, create_sidebars: { value: true, source: '1' }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: true, source: '1' }, remove_members: { value: true, source: '1' }, change_role: { value: true, source: '1' }, leave_thread: { value: false, source: null }, react_to_message: { value: true, source: '1' }, edit_message: { value: true, source: '1' }, }, isSender: false, }, ], roles: { '83795': { id: '83795', name: 'Members', permissions: { know_of: true, visible: true, descendant_open_know_of: true, descendant_open_visible: true, descendant_opentoplevel_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '83795', permissions: { know_of: { value: true, source: '1' }, membership: { value: false, source: null }, visible: { value: true, source: '1' }, voiced: { value: false, source: null }, edit_entries: { value: false, source: null }, edit_thread: { value: false, source: null }, edit_thread_description: { value: false, source: null }, edit_thread_color: { value: false, source: null }, delete_thread: { value: false, source: null }, create_subthreads: { value: false, source: null }, create_sidebars: { value: false, source: null }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: false, source: null }, remove_members: { value: false, source: null }, change_role: { value: false, source: null }, leave_thread: { value: false, source: null }, react_to_message: { value: false, source: null }, edit_message: { value: false, source: null }, }, subscription: { home: true, pushNotifs: true }, unread: true, }, repliesCount: 0, containingThreadID: null, community: null, }, }, userInfos: [ { id: '5', username: 'commbot' }, { id: '256', username: 'ashoat' }, { id: '93079', username: 'temp_user7' }, ], }, }; expect(registerResponseValidator.is(response)).toBe(true); response.cookieChange.userInfos = undefined; expect(registerResponseValidator.is(response)).toBe(false); }); it('should validate login response', () => { const response = { currentUserInfo: { id: '93079', username: 'temp_user7' }, rawMessageInfos: [ { type: 0, id: '93115', threadID: '93094', time: 1682086407577, creatorID: '5', text: 'This is your private chat, where you can set', }, { type: 1, id: '93111', threadID: '93094', time: 1682086407467, creatorID: '93079', initialThreadState: { type: 7, name: 'temp_user7', parentThreadID: '1', color: '575757', memberIDs: ['93079'], }, }, ], truncationStatuses: { '93094': 'exhaustive', '93095': 'exhaustive' }, serverTime: 1682086579416, userInfos: [ { id: '5', username: 'commbot' }, { id: '256', username: 'ashoat' }, { id: '93079', username: 'temp_user7' }, ], cookieChange: { threadInfos: { '1': { id: '1', type: 12, name: 'GENESIS', description: 'This is the first community on Comm. In the future it will', color: 'c85000', creationTime: 1672934346213, parentThreadID: null, members: [ { id: '256', role: '83796', permissions: { know_of: { value: true, source: '1' }, membership: { value: false, source: null }, visible: { value: true, source: '1' }, voiced: { value: true, source: '1' }, edit_entries: { value: true, source: '1' }, edit_thread: { value: true, source: '1' }, edit_thread_description: { value: true, source: '1' }, edit_thread_color: { value: true, source: '1' }, delete_thread: { value: true, source: '1' }, create_subthreads: { value: true, source: '1' }, create_sidebars: { value: true, source: '1' }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: true, source: '1' }, remove_members: { value: true, source: '1' }, change_role: { value: true, source: '1' }, leave_thread: { value: false, source: null }, react_to_message: { value: true, source: '1' }, edit_message: { value: true, source: '1' }, }, isSender: false, }, { id: '93079', role: '83795', permissions: { know_of: { value: true, source: '1' }, membership: { value: false, source: null }, visible: { value: true, source: '1' }, voiced: { value: false, source: null }, edit_entries: { value: false, source: null }, edit_thread: { value: false, source: null }, edit_thread_description: { value: false, source: null }, edit_thread_color: { value: false, source: null }, delete_thread: { value: false, source: null }, create_subthreads: { value: false, source: null }, create_sidebars: { value: false, source: null }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: false, source: null }, remove_members: { value: false, source: null }, change_role: { value: false, source: null }, leave_thread: { value: false, source: null }, react_to_message: { value: false, source: null }, edit_message: { value: false, source: null }, }, isSender: false, }, ], roles: { '83795': { id: '83795', name: 'Members', permissions: { know_of: true, visible: true, descendant_open_know_of: true, descendant_open_visible: true, descendant_opentoplevel_join_thread: true, }, isDefault: true, }, '83796': { id: '83796', name: 'Admins', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_subthreads: true, create_sidebars: true, add_members: true, delete_thread: true, remove_members: true, change_role: true, descendant_know_of: true, descendant_visible: true, descendant_toplevel_join_thread: true, child_join_thread: true, descendant_voiced: true, descendant_edit_entries: true, descendant_edit_thread: true, descendant_edit_thread_color: true, descendant_edit_thread_description: true, descendant_toplevel_create_subthreads: true, descendant_toplevel_create_sidebars: true, descendant_add_members: true, descendant_delete_thread: true, descendant_edit_permissions: true, descendant_remove_members: true, descendant_change_role: true, }, isDefault: false, }, }, currentUser: { role: '83795', permissions: { know_of: { value: true, source: '1' }, membership: { value: false, source: null }, visible: { value: true, source: '1' }, voiced: { value: false, source: null }, edit_entries: { value: false, source: null }, edit_thread: { value: false, source: null }, edit_thread_description: { value: false, source: null }, edit_thread_color: { value: false, source: null }, delete_thread: { value: false, source: null }, create_subthreads: { value: false, source: null }, create_sidebars: { value: false, source: null }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: false, source: null }, remove_members: { value: false, source: null }, change_role: { value: false, source: null }, leave_thread: { value: false, source: null }, react_to_message: { value: false, source: null }, edit_message: { value: false, source: null }, }, subscription: { home: true, pushNotifs: true }, unread: true, }, repliesCount: 0, containingThreadID: null, community: null, }, }, userInfos: [], }, rawEntryInfos: [], }; expect(logInResponseValidator.is(response)).toBe(true); expect( logInResponseValidator.is({ ...response, currentUserInfo: undefined }), ).toBe(false); }); }); describe('search responder', () => { it('should validate search response', () => { const response = { userInfos: [ { id: '83817', username: 'temp_user0' }, { id: '83853', username: 'temp_user1' }, { id: '83890', username: 'temp_user2' }, { id: '83928', username: 'temp_user3' }, ], }; expect(userSearchResultValidator.is(response)).toBe(true); response.userInfos.push({ id: 123 }); expect(userSearchResultValidator.is(response)).toBe(false); }); }); describe('message report responder', () => { it('should validate message report response', () => { const response = { messageInfo: { type: 0, threadID: '101113', creatorID: '5', time: 1682429699746, text: 'text', id: '101121', }, }; expect(messageReportCreationResultValidator.is(response)).toBe(true); response.messageInfo.type = -2; expect(messageReportCreationResultValidator.is(response)).toBe(false); }); }); describe('relationship responder', () => { it('should validate relationship response', () => { const response = { invalid_user: ['83817', '83890'], already_friends: ['83890'], }; expect(relationshipErrorsValidator.is(response)).toBe(true); expect( relationshipErrorsValidator.is({ ...response, user_blocked: {} }), ).toBe(false); }); }); describe('activity responder', () => { it('should validate update activity response', () => { const response = { unfocusedToUnread: ['93095'] }; expect(updateActivityResultValidator.is(response)).toBe(true); response.unfocusedToUnread.push(123); expect(updateActivityResultValidator.is(response)).toBe(false); }); it('should validate set thread unread response', () => { const response = { resetToUnread: false }; expect(setThreadUnreadStatusResult.is(response)).toBe(true); expect(setThreadUnreadStatusResult.is({ ...response, unread: false })).toBe( false, ); }); }); describe('keys responder', () => { it('should validate get session public keys response', () => { const response = { identityKey: 'key', oneTimeKey: 'key', }; expect(getSessionPublicKeysResponseValidator.is(response)).toBe(true); expect(getSessionPublicKeysResponseValidator.is(null)).toBe(true); expect( getSessionPublicKeysResponseValidator.is({ ...response, identityKey: undefined, }), ).toBe(false); }); }); describe('siwe nonce responders', () => { it('should validate siwe nonce response', () => { const response = { nonce: 'nonce' }; expect(siweNonceResponseValidator.is(response)).toBe(true); expect(siweNonceResponseValidator.is({ nonce: 123 })).toBe(false); }); }); describe('entry reponders', () => { it('should validate entry fetch response', () => { const response = { rawEntryInfos: [ { id: '92860', threadID: '85068', text: 'text', year: 2023, month: 4, day: 2, creationTime: 1682082939882, creatorID: '83853', deleted: false, }, ], userInfos: { '123': { id: '123', username: 'username', }, }, }; expect(fetchEntryInfosResponseValidator.is(response)).toBe(true); expect( fetchEntryInfosResponseValidator.is({ ...response, userInfos: undefined, }), ).toBe(false); }); it('should validate entry revision fetch response', () => { const response = { result: [ { id: '93297', authorID: '83853', text: 'text', lastUpdate: 1682603494202, deleted: false, threadID: '83859', entryID: '93270', }, { id: '93284', authorID: '83853', text: 'text', lastUpdate: 1682603426996, deleted: true, threadID: '83859', entryID: '93270', }, ], }; expect(fetchEntryRevisionInfosResultValidator.is(response)).toBe(true); expect( fetchEntryRevisionInfosResultValidator.is({ ...response, result: {}, }), ).toBe(false); }); it('should validate entry save response', () => { const response = { entryID: '93270', newMessageInfos: [ { type: 9, threadID: '83859', creatorID: '83853', time: 1682603362817, entryID: '93270', date: '2023-04-03', text: 'text', id: '93272', }, ], updatesResult: { viewerUpdates: [], userInfos: [] }, }; expect(saveEntryResponseValidator.is(response)).toBe(true); expect( saveEntryResponseValidator.is({ ...response, entryID: undefined, }), ).toBe(false); }); it('should validate entry delete response', () => { const response = { threadID: '83859', newMessageInfos: [ { type: 11, threadID: '83859', creatorID: '83853', time: 1682603427038, entryID: '93270', date: '2023-04-03', text: 'text', id: '93285', }, ], updatesResult: { viewerUpdates: [], userInfos: [] }, }; expect(deleteEntryResponseValidator.is(response)).toBe(true); expect( deleteEntryResponseValidator.is({ ...response, threadID: undefined, }), ).toBe(false); }); it('should validate entry restore response', () => { const response = { newMessageInfos: [ { type: 11, threadID: '83859', creatorID: '83853', time: 1682603427038, entryID: '93270', date: '2023-04-03', text: 'text', id: '93285', }, ], updatesResult: { viewerUpdates: [], userInfos: [] }, }; expect(restoreEntryResponseValidator.is(response)).toBe(true); expect( restoreEntryResponseValidator.is({ ...response, newMessageInfos: undefined, }), ).toBe(false); }); it('should validate entry delta response', () => { const response = { rawEntryInfos: [ { id: '92860', threadID: '85068', text: 'text', year: 2023, month: 4, day: 2, creationTime: 1682082939882, creatorID: '83853', deleted: false, }, ], deletedEntryIDs: ['92860'], userInfos: [ { id: '123', username: 'username', }, ], }; expect(deltaEntryInfosResultValidator.is(response)).toBe(true); expect( deltaEntryInfosResultValidator.is({ ...response, rawEntryInfos: undefined, }), ).toBe(false); }); }); describe('thread responders', () => { it('should validate change thread settings response', () => { const response = { updatesResult: { newUpdates: [ { type: 1, id: '93601', time: 1682759546258, threadInfo: { id: '92796', type: 6, name: '', description: '', color: 'b8753d', creationTime: 1682076700918, parentThreadID: '1', members: [], roles: {}, currentUser: { role: '85172', permissions: {}, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }, }, ], }, newMessageInfos: [ { type: 4, threadID: '92796', creatorID: '83928', time: 1682759546275, field: 'color', value: 'b8753d', id: '93602', }, ], }; expect(changeThreadSettingsResultValidator.is(response)).toBe(true); expect( changeThreadSettingsResultValidator.is({ ...response, newMessageInfos: undefined, }), ).toBe(false); }); it('should validate leave thread response', () => { const response = { updatesResult: { newUpdates: [ { type: 3, id: '93595', time: 1682759498811, threadID: '93561' }, ], }, }; expect(leaveThreadResultValidator.is(response)).toBe(true); expect( leaveThreadResultValidator.is({ ...response, updatedResult: undefined, }), ).toBe(false); }); it('should validate new thread response', () => { const response = { newThreadID: '93619', updatesResult: { newUpdates: [ { type: 4, id: '93621', time: 1682759805331, threadInfo: { id: '93619', type: 5, name: 'a', description: '', color: 'b8753d', creationTime: 1682759805298, parentThreadID: '92796', members: [], roles: {}, currentUser: { role: '85172', permissions: {}, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '92796', community: '1', sourceMessageID: '93614', pinnedCount: 0, }, rawMessageInfos: [], truncationStatus: 'exhaustive', rawEntryInfos: [], }, ], }, userInfos: { '256': { id: '256', username: 'ashoat' }, '83928': { id: '83928', username: 'temp_user3' }, }, newMessageInfos: [], }; expect(newThreadResponseValidator.is(response)).toBe(true); expect( newThreadResponseValidator.is({ ...response, newMessageInfos: {}, }), ).toBe(false); }); it('should validate thread join response', () => { const response = { rawMessageInfos: [ { type: 8, threadID: '93619', creatorID: '83928', time: 1682759915935, id: '93640', }, ], truncationStatuses: {}, userInfos: { '256': { id: '256', username: 'ashoat' }, '83928': { id: '83928', username: 'temp_user3' }, }, updatesResult: { newUpdates: [], }, }; expect(threadJoinResultValidator.is(response)).toBe(true); expect( threadJoinResultValidator.is({ ...response, updatesResult: [], }), ).toBe(false); }); it('should validate thread fetch media response', () => { const response = { media: [ { type: 'photo', id: '93642', uri: 'http://0.0.0.0:3000/comm/upload/93642/1e0d7a5262952e3b', dimensions: { width: 220, height: 220 }, }, ], }; expect(threadFetchMediaResultValidator.is(response)).toBe(true); expect( threadFetchMediaResultValidator.is({ ...response, media: undefined }), ).toBe(false); }); it('should validate toggle message pin response', () => { const response = { threadID: '123', newMessageInfos: [] }; expect(toggleMessagePinResultValidator.is(response)).toBe(true); expect( toggleMessagePinResultValidator.is({ ...response, threadID: undefined }), ).toBe(false); }); + + it('should validate role change request input', () => { + const input = { + threadID: '123', + memberIDs: [], + role: '1', + }; + + expect(roleChangeRequestInputValidator.is(input)).toBe(true); + expect(roleChangeRequestInputValidator.is({ ...input, role: '2|1' })).toBe( + true, + ); + expect(roleChangeRequestInputValidator.is({ ...input, role: '-1' })).toBe( + false, + ); + expect(roleChangeRequestInputValidator.is({ ...input, role: '2|-1' })).toBe( + false, + ); + }); }); describe('message responders', () => { it('should validate send message response', () => { const response = { newMessageInfo: { type: 0, threadID: '93619', creatorID: '83928', time: 1682761023640, text: 'a', localID: 'local3', id: '93649', }, }; expect(sendMessageResponseValidator.is(response)).toBe(true); expect( sendMessageResponseValidator.is({ ...response, newMEssageInfos: undefined, }), ).toBe(false); }); it('should validate fetch message infos response', () => { const response = { rawMessageInfos: [ { type: 0, id: '83954', threadID: '83938', time: 1673561155110, creatorID: '256', text: 'welcome to Comm!', }, ], truncationStatuses: { '83938': 'exhaustive' }, userInfos: { '256': { id: '256', username: 'ashoat' }, '83928': { id: '83928', username: 'temp_user3' }, }, }; expect(fetchMessageInfosResponseValidator.is(response)).toBe(true); expect( fetchMessageInfosResponseValidator.is({ ...response, userInfos: undefined, }), ).toBe(false); }); it('should validate send edit message response', () => { const response = { newMessageInfos: [ { type: 0, id: '83954', threadID: '83938', time: 1673561155110, creatorID: '256', text: 'welcome to Comm!', }, ], }; expect(sendEditMessageResponseValidator.is(response)).toBe(true); expect( sendEditMessageResponseValidator.is({ ...response, newMessageInfos: undefined, }), ).toBe(false); }); it('should validate fetch pinned message response', () => { const response = { pinnedMessages: [ { type: 0, id: '83954', threadID: '83938', time: 1673561155110, creatorID: '256', text: 'welcome to Comm!', }, ], }; expect(fetchPinnedMessagesResultValidator.is(response)).toBe(true); expect( fetchPinnedMessagesResultValidator.is({ ...response, pinnedMessages: undefined, }), ).toBe(false); }); }); describe('report responders', () => { it('should validate report creation response', () => { const response = { id: '123' }; expect(reportCreationResponseValidator.is(response)).toBe(true); expect(reportCreationResponseValidator.is({})).toBe(false); }); }); diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js index 86020324b..92d3418c7 100644 --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -1,299 +1,303 @@ // @flow import t from 'tcomb'; import type { TInterface, TUnion } from 'tcomb'; import { rawEntryInfoValidator } from 'lib/types/entry-types.js'; import { mediaValidator } from 'lib/types/media-types.js'; import { rawMessageInfoValidator, messageTruncationStatusesValidator, } from 'lib/types/message-types.js'; import { type ThreadDeletionRequest, type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerNewThreadRequest, type NewThreadResponse, type ServerThreadJoinRequest, type ThreadJoinResult, type ThreadFetchMediaResult, type ThreadFetchMediaRequest, type ToggleMessagePinRequest, type ToggleMessagePinResult, threadTypes, rawThreadInfoValidator, } from 'lib/types/thread-types.js'; import { serverUpdateInfoValidator } from 'lib/types/update-types.js'; import { userInfosValidator } from 'lib/types/user-types.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { values } from 'lib/utils/objects.js'; import { tShape, tNumEnum, tColor, tPassword, tID, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { createThread } from '../creators/thread-creator.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { fetchMediaForThread } from '../fetchers/upload-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { updateRole, removeMembers, leaveThread, updateThread, joinThread, toggleMessagePinForThread, } from '../updaters/thread-updaters.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; const threadDeletionRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, accountPassword: t.maybe(tPassword), }); export const leaveThreadResultValidator: TInterface = tShape({ threadInfos: t.maybe(t.dict(tID, rawThreadInfoValidator)), updatesResult: tShape({ newUpdates: t.list(serverUpdateInfoValidator), }), }); async function threadDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadDeletionRequest = input; await validateInput(viewer, threadDeletionRequestInputValidator, request); const result = await deleteThread(viewer, request); return validateOutput(viewer, leaveThreadResultValidator, result); } -const roleChangeRequestInputValidator = tShape({ - threadID: t.String, - memberIDs: t.list(t.String), - role: t.refinement(t.String, str => { - const int = parseInt(str, 10); - return String(int) === str && int > 0; - }), -}); +export const roleChangeRequestInputValidator: TInterface = + tShape({ + threadID: tID, + memberIDs: t.list(t.String), + role: t.refinement(tID, str => { + if (str.indexOf('|') !== -1) { + str = str.split('|')[1]; + } + const int = parseInt(str, 10); + return String(int) === str && int > 0; + }), + }); export const changeThreadSettingsResultValidator: TInterface = tShape({ newMessageInfos: t.list(rawMessageInfoValidator), threadInfo: t.maybe(rawThreadInfoValidator), threadInfos: t.maybe(t.dict(tID, rawThreadInfoValidator)), updatesResult: tShape({ newUpdates: t.list(serverUpdateInfoValidator), }), }); async function roleUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: RoleChangeRequest = input; await validateInput(viewer, roleChangeRequestInputValidator, request); const result = await updateRole(viewer, request); return validateOutput(viewer, changeThreadSettingsResultValidator, result); } const removeMembersRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, memberIDs: t.list(t.String), }); async function memberRemovalResponder( viewer: Viewer, input: any, ): Promise { const request: RemoveMembersRequest = input; await validateInput(viewer, removeMembersRequestInputValidator, request); const result = await removeMembers(viewer, request); return validateOutput(viewer, changeThreadSettingsResultValidator, result); } const leaveThreadRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, }); async function threadLeaveResponder( viewer: Viewer, input: any, ): Promise { const request: LeaveThreadRequest = input; await validateInput(viewer, leaveThreadRequestInputValidator, request); const result = await leaveThread(viewer, request); return validateOutput(viewer, leaveThreadResultValidator, result); } const updateThreadRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, changes: tShape({ type: t.maybe(tNumEnum(values(threadTypes))), name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), - parentThreadID: t.maybe(t.String), + parentThreadID: t.maybe(tID), newMemberIDs: t.maybe(t.list(t.String)), avatar: t.maybe(updateUserAvatarRequestValidator), }), accountPassword: t.maybe(tPassword), }); async function threadUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateThreadRequest = input; await validateInput(viewer, updateThreadRequestInputValidator, request); const result = await updateThread(viewer, request); return validateOutput(viewer, changeThreadSettingsResultValidator, result); } const threadRequestValidationShape = { name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), - parentThreadID: t.maybe(t.String), + parentThreadID: t.maybe(tID), initialMemberIDs: t.maybe(t.list(t.String)), calendarQuery: t.maybe(entryQueryInputValidator), }; const newThreadRequestInputValidator: TUnion = t.union([ tShape({ type: tNumEnum([threadTypes.SIDEBAR]), - sourceMessageID: t.String, + sourceMessageID: tID, ...threadRequestValidationShape, }), tShape({ type: tNumEnum([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.PERSONAL, threadTypes.LOCAL, ]), ...threadRequestValidationShape, }), ]); export const newThreadResponseValidator: TInterface = tShape({ updatesResult: tShape({ newUpdates: t.list(serverUpdateInfoValidator), }), newMessageInfos: t.list(rawMessageInfoValidator), newThreadInfo: t.maybe(rawThreadInfoValidator), userInfos: userInfosValidator, newThreadID: t.maybe(tID), }); async function threadCreationResponder( viewer: Viewer, input: any, ): Promise { const request: ServerNewThreadRequest = input; await validateInput(viewer, newThreadRequestInputValidator, request); const result = await createThread(viewer, request, { silentlyFailMembers: request.type === threadTypes.SIDEBAR, }); return validateOutput(viewer, newThreadResponseValidator, result); } const joinThreadRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, calendarQuery: t.maybe(entryQueryInputValidator), inviteLinkSecret: t.maybe(t.String), }); export const threadJoinResultValidator: TInterface = tShape({ threadInfos: t.maybe(t.dict(tID, rawThreadInfoValidator)), updatesResult: tShape({ newUpdates: t.list(serverUpdateInfoValidator), }), rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, userInfos: userInfosValidator, rawEntryInfos: t.maybe(t.list(rawEntryInfoValidator)), }); async function threadJoinResponder( viewer: Viewer, input: any, ): Promise { const request: ServerThreadJoinRequest = input; await validateInput(viewer, joinThreadRequestInputValidator, request); if (request.calendarQuery) { await verifyCalendarQueryThreadIDs(request.calendarQuery); } const result = await joinThread(viewer, request); return validateOutput(viewer, threadJoinResultValidator, result); } const threadFetchMediaRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, limit: t.Number, offset: t.Number, }); export const threadFetchMediaResultValidator: TInterface = tShape({ media: t.list(mediaValidator) }); async function threadFetchMediaResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadFetchMediaRequest = input; await validateInput(viewer, threadFetchMediaRequestInputValidator, request); const result = await fetchMediaForThread(viewer, request); return validateOutput(viewer, threadFetchMediaResultValidator, result); } const toggleMessagePinRequestInputValidator = tShape({ - messageID: t.String, + messageID: tID, action: t.enums.of(['pin', 'unpin']), }); export const toggleMessagePinResultValidator: TInterface = tShape({ newMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, }); async function toggleMessagePinResponder( viewer: Viewer, input: any, ): Promise { const request: ToggleMessagePinRequest = input; await validateInput(viewer, toggleMessagePinRequestInputValidator, request); const result = await toggleMessagePinForThread(viewer, request); return validateOutput(viewer, toggleMessagePinResultValidator, result); } export { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, threadFetchMediaResponder, newThreadRequestInputValidator, toggleMessagePinResponder, }; diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index 386a12e7c..c3b065881 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,737 +1,737 @@ // @flow import type { Utility as OlmUtility } from '@commapp/olm'; import invariant from 'invariant'; import { ErrorTypes, SiweMessage } from 'siwe'; import t, { type TInterface } from 'tcomb'; import bcrypt from 'twin-bcrypt'; import { baseLegalPolicies, policies, policyTypeValidator, } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from 'lib/types/account-types.js'; import { userSettingsTypes, notificationTypeValues, logInActionSources, } from 'lib/types/account-types.js'; import { type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarRequest, type UpdateUserAvatarResponse, } from 'lib/types/avatar-types.js'; import type { IdentityKeysBlob, SignedIdentityKeysBlob, } from 'lib/types/crypto-types.js'; import { type CalendarQuery, rawEntryInfoValidator, } from 'lib/types/entry-types.js'; import { defaultNumberPerThread, rawMessageInfoValidator, messageTruncationStatusesValidator, } from 'lib/types/message-types.js'; import type { SIWEAuthRequest, SIWEMessage, SIWESocialProof, } from 'lib/types/siwe-types.js'; import { type SubscriptionUpdateRequest, type SubscriptionUpdateResponse, threadSubscriptionValidator, } from 'lib/types/subscription-types.js'; import { rawThreadInfoValidator } from 'lib/types/thread-types.js'; import { createUpdatesResultValidator } from 'lib/types/update-types.js'; import { type PasswordUpdate, loggedOutUserInfoValidator, loggedInUserInfoValidator, oldLoggedInUserInfoValidator, userInfoValidator, } from 'lib/types/user-types.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { identityKeysBlobValidator, signedIdentityKeysBlobValidator, } from 'lib/utils/crypto-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { getPublicKeyFromSIWEStatement, isValidSIWEMessage, isValidSIWEStatementWithPublicKey, primaryIdentityPublicKeyRegex, } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, tRegex, tID, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { createAccount, processSIWEAccountCreation, } from '../creators/account-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteAccount } from '../deleters/account-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, fetchUserIDForEthereumAddress, } from '../fetchers/user-fetchers.js'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, updateUserAvatar, } from '../updaters/account-updaters.js'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { getOlmUtility } from '../utils/olm-utils.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; const subscriptionUpdateRequestInputValidator = tShape({ - threadID: t.String, + threadID: tID, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); export const subscriptionUpdateResponseValidator: TInterface = tShape({ threadSubscription: threadSubscriptionValidator, }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return validateOutput(viewer, subscriptionUpdateResponseValidator, { threadSubscription, }); } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: PasswordUpdate = 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.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } export const logOutResponseValidator: TInterface = tShape({ currentUserInfo: loggedOutUserInfoValidator, }); 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); } const response = { currentUserInfo: { id: viewer.id, anonymous: true, }, }; return validateOutput(viewer, logOutResponseValidator, response); } const deleteAccountRequestInputValidator = tShape({ password: t.maybe(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 validateOutput(viewer, logOutResponseValidator, result); } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); export const registerResponseValidator: TInterface = tShape({ id: t.String, rawMessageInfos: t.list(rawMessageInfoValidator), currentUserInfo: t.union([ oldLoggedInUserInfoValidator, loggedInUserInfoValidator, ]), cookieChange: tShape({ threadInfos: t.dict(t.String, rawThreadInfoValidator), userInfos: t.list(userInfoValidator), }), }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); const { signedIdentityKeysBlob } = request; if (signedIdentityKeysBlob) { const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } const response = await createAccount(viewer, request); return validateOutput(viewer, registerResponseValidator, response); } type ProcessSuccessfulLoginParams = { +viewer: Viewer, +input: any, +userID: string, +calendarQuery: ?CalendarQuery, +socialProof?: ?SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, }; async function processSuccessfulLogin( params: ProcessSuccessfulLoginParams, ): Promise { const { viewer, input, userID, calendarQuery, socialProof, signedIdentityKeysBlob, } = params; const request: LogInRequest = input; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData, notAcknowledgedPolicies] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, socialProof, signedIdentityKeysBlob, }), fetchNotAcknowledgedPolicies(userID, baseLegalPolicies), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if ( notAcknowledgedPolicies.length && hasMinCodeVersion(viewer.platformDetails, 181) ) { const currentUserInfo = await fetchLoggedInUserInfo(viewer); return { notAcknowledgedPolicies, currentUserInfo: currentUserInfo, rawMessageInfos: [], truncationStatuses: {}, userInfos: [], rawEntryInfos: [], serverTime: 0, cookieChange: { threadInfos: {}, userInfos: [], }, }; } if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { return { ...response, rawEntryInfos, }; } return response; } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, - watchedIDs: t.list(t.String), + watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(logInActionSources))), // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); export const logInResponseValidator: TInterface = tShape({ currentUserInfo: t.union([ loggedInUserInfoValidator, oldLoggedInUserInfoValidator, ]), rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, userInfos: t.list(userInfoValidator), rawEntryInfos: t.maybe(t.list(rawEntryInfoValidator)), serverTime: t.Number, cookieChange: tShape({ threadInfos: t.dict(tID, rawThreadInfoValidator), userInfos: t.list(userInfoValidator), }), notAcknowledgedPolicies: t.maybe(t.list(policyTypeValidator)), }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; let identityKeys: ?IdentityKeysBlob; const { signedIdentityKeysBlob } = request; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs(calendarQuery); } const username = request.username ?? request.usernameOrEmail; if (!username) { if (hasMinCodeVersion(viewer.platformDetails, 150)) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { if (hasMinCodeVersion(viewer.platformDetails, 150)) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); const response = await processSuccessfulLogin({ viewer, input, userID: id, calendarQuery, signedIdentityKeysBlob, }); return validateOutput(viewer, logInResponseValidator, response); } const siweAuthRequestInputValidator = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, - watchedIDs: t.list(t.String), + watchedIDs: t.list(tID), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); async function siweAuthResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, siweAuthRequestInputValidator, input); const request: SIWEAuthRequest = input; const { message, signature, deviceTokenUpdateRequest, platformDetails, signedIdentityKeysBlob, } = request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Ensure that `message` is a well formed Comm SIWE Auth message. const siweMessage: SIWEMessage = new SiweMessage(message); if (!isValidSIWEMessage(siweMessage)) { throw new ServerError('invalid_parameters'); } // 2. Ensure that the `nonce` exists in the `siwe_nonces` table // AND hasn't expired. If those conditions are met, delete the entry to // ensure that the same `nonce` can't be re-used in a future request. const wasNonceCheckedAndInvalidated = await checkAndInvalidateSIWENonceEntry( siweMessage.nonce, ); if (!wasNonceCheckedAndInvalidated) { throw new ServerError('invalid_parameters'); } // 3. Validate SIWEMessage signature and handle possible errors. try { await siweMessage.validate(signature); } catch (error) { if (error === ErrorTypes.EXPIRED_MESSAGE) { // Thrown when the `expirationTime` is present and in the past. throw new ServerError('expired_message'); } else if (error === ErrorTypes.INVALID_SIGNATURE) { // Thrown when the `validate()` function can't verify the message. throw new ServerError('invalid_signature'); } else if (error === ErrorTypes.MALFORMED_SESSION) { // Thrown when some required field is missing. throw new ServerError('malformed_session'); } else { throw new ServerError('unknown_error'); } } // 4. Pull `primaryIdentityPublicKey` out from SIWEMessage `statement`. // We expect it to be included for BOTH native and web clients. const { statement } = siweMessage; const primaryIdentityPublicKey = statement && isValidSIWEStatementWithPublicKey(statement) ? getPublicKeyFromSIWEStatement(statement) : null; if (!primaryIdentityPublicKey) { throw new ServerError('invalid_siwe_statement_public_key'); } // 5. Verify `signedIdentityKeysBlob.payload` with included `signature` // if `signedIdentityKeysBlob` was included in the `SIWEAuthRequest`. let identityKeys: ?IdentityKeysBlob; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } // 6. Ensure that `primaryIdentityPublicKeys.ed25519` matches SIWE // statement `primaryIdentityPublicKey` if `identityKeys` exists. if ( identityKeys && identityKeys.primaryIdentityPublicKeys.ed25519 !== primaryIdentityPublicKey ) { throw new ServerError('primary_public_key_mismatch'); } // 7. Construct `SIWESocialProof` object with the stringified // SIWEMessage and the corresponding signature. const socialProof: SIWESocialProof = { siweMessage: siweMessage.toMessage(), siweMessageSignature: signature, }; // 8. Create account with call to `processSIWEAccountCreation(...)` // if address does not correspond to an existing user. let userID = await fetchUserIDForEthereumAddress(siweMessage.address); if (!userID) { const siweAccountCreationRequest = { address: siweMessage.address, calendarQuery, deviceTokenUpdateRequest, platformDetails, socialProof, }; userID = await processSIWEAccountCreation( viewer, siweAccountCreationRequest, ); } // 9. Complete login with call to `processSuccessfulLogin(...)`. const response = await processSuccessfulLogin({ viewer, input, userID, calendarQuery, socialProof, signedIdentityKeysBlob, }); return validateOutput(viewer, logInResponseValidator, response); } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, - watchedIDs: t.list(t.String), + watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } const response = await updatePassword(viewer, request); return validateOutput(viewer, logInResponseValidator, response); } const updateUserSettingsInputValidator = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserSettingsRequest = input; await validateInput(viewer, updateUserSettingsInputValidator, request); await updateUserSettings(viewer, request); } const policyAcknowledgmentRequestInputValidator = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, input: any, ): Promise { const request: PolicyAcknowledgmentRequest = input; await validateInput( viewer, policyAcknowledgmentRequestInputValidator, request, ); await viewerAcknowledgmentUpdater(viewer, request.policy); } export const updateUserAvatarResponseValidator: TInterface = tShape({ updates: createUpdatesResultValidator, }); const updateUserAvatarResponderValidator = t.union([ t.maybe(clientAvatarValidator), updateUserAvatarResponseValidator, ]); async function updateUserAvatarResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserAvatarRequest = input; await validateInput(viewer, updateUserAvatarRequestValidator, request); const result = await updateUserAvatar(viewer, request); return validateOutput(viewer, updateUserAvatarResponderValidator, result); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, }; diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index 6e618289b..6c76c1a33 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,329 +1,329 @@ // @flow import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import type { TType, TInterface } from 'tcomb'; import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { isWebPlatform } from 'lib/types/device-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tCookie, tPassword, tPlatform, tPlatformDetails, assertWithValidator, tID, } from 'lib/utils/validation-utils.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { verifyClientSupported } from '../session/version.js'; import type { Viewer } from '../session/viewer.js'; async function validateInput( viewer: Viewer, inputValidator: ?TType, input: T, ) { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } checkInputValidator(inputValidator, input); } const convertToNewIDSchema = false; const keyserverPrefixID = '256'; function validateOutput( viewer: Viewer, outputValidator: TType, data: T, ): T { if (!outputValidator.is(data)) { console.trace( 'Output validation failed, validator is:', outputValidator.displayName, ); return data; } if ( hasMinCodeVersion(viewer.platformDetails, 1000) && !isWebPlatform(viewer.platformDetails?.platform) && convertToNewIDSchema ) { return convertServerIDsToClientIDs( keyserverPrefixID, outputValidator, data, ); } return data; } function convertServerIDsToClientIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const conversionFunction = id => { if (id.indexOf('|') !== -1) { console.warn(`Server id '${id}' already has a prefix`); return id; } return `${serverPrefixID}|${id}`; }; return convertObject(outputValidator, data, [tID], conversionFunction); } function convertClientIDsToServerIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const prefix = serverPrefixID + '|'; const conversionFunction = id => { if (id.startsWith(prefix)) { return id.substr(prefix.length); } throw new ServerError('invalid_client_id_prefix'); }; return convertObject(outputValidator, data, [tID], conversionFunction); } function checkInputValidator(inputValidator: ?TType, input: T) { if (!inputValidator || inputValidator.is(input)) { return; } const error = new ServerError('invalid_parameters'); error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; throw error; } async function checkClientSupported( viewer: Viewer, inputValidator: ?TType, input: T, ) { let platformDetails; if (inputValidator) { platformDetails = findFirstInputMatchingValidator( inputValidator, tPlatformDetails, input, ); } if (!platformDetails && inputValidator) { const platform = findFirstInputMatchingValidator( inputValidator, tPlatform, input, ); if (platform) { platformDetails = { platform }; } } if (!platformDetails) { ({ platformDetails } = viewer); } await verifyClientSupported(viewer, platformDetails); } const redactedString = '********'; const redactedTypes = [tPassword, tCookie]; function sanitizeInput(inputValidator: TType, input: T): T { return convertObject( inputValidator, input, redactedTypes, () => redactedString, ); } function findFirstInputMatchingValidator( wholeInputValidator: *, inputValidatorToMatch: *, input: *, ): any { if (!wholeInputValidator || input === null || input === undefined) { return null; } if ( wholeInputValidator === inputValidatorToMatch && wholeInputValidator.is(input) ) { return input; } if (wholeInputValidator.meta.kind === 'maybe') { return findFirstInputMatchingValidator( wholeInputValidator.meta.type, inputValidatorToMatch, input, ); } if ( wholeInputValidator.meta.kind === 'interface' && typeof input === 'object' ) { for (const key in input) { const value = input[key]; const validator = wholeInputValidator.meta.props[key]; const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } if (wholeInputValidator.meta.kind === 'union') { for (const validator of wholeInputValidator.meta.types) { if (validator.is(input)) { return findFirstInputMatchingValidator( validator, inputValidatorToMatch, input, ); } } } if (wholeInputValidator.meta.kind === 'list' && Array.isArray(input)) { const validator = wholeInputValidator.meta.type; for (const value of input) { const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } return null; } function convertObject( validator: TType, input: I, typesToConvert: $ReadOnlyArray>, conversionFunction: T => T, ): I { if (input === null || input === undefined) { return input; } // While they should be the same runtime object, // `TValidator` is `TType` and `validator` is `TType`. // Having them have different types allows us to use `assertWithValidator` // to change `input` flow type const TValidator = typesToConvert[typesToConvert.indexOf(validator)]; if (TValidator && TValidator.is(input)) { const TInput = assertWithValidator(input, TValidator); const converted = conversionFunction(TInput); return assertWithValidator(converted, validator); } - if (validator.meta.kind === 'maybe') { + if (validator.meta.kind === 'maybe' || validator.meta.kind === 'subtype') { return convertObject( validator.meta.type, input, typesToConvert, conversionFunction, ); } if (validator.meta.kind === 'interface' && typeof input === 'object') { const recastValidator: TInterface = (validator: any); const result = {}; for (const key in input) { const innerValidator = recastValidator.meta.props[key]; result[key] = convertObject( innerValidator, input[key], typesToConvert, conversionFunction, ); } return assertWithValidator(result, recastValidator); } if (validator.meta.kind === 'union') { for (const innerValidator of validator.meta.types) { if (innerValidator.is(input)) { return convertObject( innerValidator, input, typesToConvert, conversionFunction, ); } } return input; } if (validator.meta.kind === 'list' && Array.isArray(input)) { const innerValidator = validator.meta.type; return (input.map(value => convertObject(innerValidator, value, typesToConvert, conversionFunction), ): any); } if (validator.meta.kind === 'dict' && typeof input === 'object') { const domainValidator = validator.meta.domain; const codomainValidator = validator.meta.codomain; if (typesToConvert.includes(domainValidator)) { input = _mapKeys(key => conversionFunction(key))(input); } return _mapValues(value => convertObject( codomainValidator, value, typesToConvert, conversionFunction, ), )(input); } return input; } async function policiesValidator( viewer: Viewer, policies: $ReadOnlyArray, ) { if (!policies.length) { return; } if (!hasMinCodeVersion(viewer.platformDetails, 181)) { return; } const notAcknowledgedPolicies = await fetchNotAcknowledgedPolicies( viewer.id, policies, ); if (notAcknowledgedPolicies.length) { throw new ServerError('policies_not_accepted', { notAcknowledgedPolicies, }); } } export { validateInput, validateOutput, checkInputValidator, redactedString, sanitizeInput, findFirstInputMatchingValidator, checkClientSupported, convertServerIDsToClientIDs, convertClientIDsToServerIDs, convertObject, policiesValidator, }; diff --git a/keyserver/src/utils/validation-utils.test.js b/keyserver/src/utils/validation-utils.test.js index 5967f20f0..4b50b7595 100644 --- a/keyserver/src/utils/validation-utils.test.js +++ b/keyserver/src/utils/validation-utils.test.js @@ -1,109 +1,122 @@ // @flow import t from 'tcomb'; import { tPassword, tShape, tID } from 'lib/utils/validation-utils.js'; import { convertServerIDsToClientIDs, sanitizeInput, redactedString, convertClientIDsToServerIDs, } from './validation-utils.js'; describe('sanitization', () => { it('should redact a string', () => { expect(sanitizeInput(tPassword, 'password')).toStrictEqual(redactedString); }); it('should redact a string inside an object', () => { const validator = tShape({ password: tPassword }); const object = { password: 'password' }; const redacted = { password: redactedString }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact an optional string', () => { const validator = tShape({ password: t.maybe(tPassword) }); const object = { password: 'password' }; const redacted = { password: redactedString }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string in optional object', () => { const validator = tShape({ obj: t.maybe(tShape({ password: tPassword })) }); const object = { obj: { password: 'password' } }; const redacted = { obj: { password: redactedString } }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string array', () => { const validator = tShape({ passwords: t.list(tPassword) }); const object = { passwords: ['password', 'password'] }; const redacted = { passwords: [redactedString, redactedString] }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside a dict', () => { const validator = tShape({ passwords: t.dict(t.String, tPassword) }); const object = { passwords: { a: 'password', b: 'password' } }; const redacted = { passwords: { a: redactedString, b: redactedString } }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact password dict key', () => { const validator = tShape({ passwords: t.dict(tPassword, t.Bool) }); const object = { passwords: { password1: true, password2: false } }; const redacted = { passwords: {} }; redacted.passwords[redactedString] = false; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside a union', () => { const validator = tShape({ password: t.union([tPassword, t.String, t.Bool]), }); const object = { password: 'password' }; const redacted = { password: redactedString }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside an object array', () => { const validator = tShape({ passwords: t.list(tShape({ password: tPassword })), }); const object = { passwords: [{ password: 'password' }] }; const redacted = { passwords: [{ password: redactedString }] }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); }); describe('id conversion', () => { it('should convert string id', () => { const validator = tShape({ id: tID }); const serverData = { id: '1' }; const clientData = { id: '0|1' }; expect( convertServerIDsToClientIDs('0', validator, serverData), ).toStrictEqual(clientData); expect( convertClientIDsToServerIDs('0', validator, clientData), ).toStrictEqual(serverData); }); it('should convert a complex type', () => { const validator = tShape({ ids: t.dict(tID, t.list(tID)) }); const serverData = { ids: { '1': ['11', '12'], '2': [], '3': ['13'] } }; const clientData = { ids: { '0|1': ['0|11', '0|12'], '0|2': [], '0|3': ['0|13'] }, }; expect( convertServerIDsToClientIDs('0', validator, serverData), ).toStrictEqual(clientData); expect( convertClientIDsToServerIDs('0', validator, clientData), ).toStrictEqual(serverData); }); + + it('should convert a refinement', () => { + const validator = t.refinement(tID, () => true); + const serverData = '1'; + const clientData = '0|1'; + + expect( + convertServerIDsToClientIDs('0', validator, serverData), + ).toStrictEqual(clientData); + expect( + convertClientIDsToServerIDs('0', validator, clientData), + ).toStrictEqual(serverData); + }); }); diff --git a/lib/utils/avatar-utils.js b/lib/utils/avatar-utils.js index 7c1bf7fa0..d4c9cd971 100644 --- a/lib/utils/avatar-utils.js +++ b/lib/utils/avatar-utils.js @@ -1,51 +1,51 @@ // @flow import t from 'tcomb'; import type { TUnion, TInterface } from 'tcomb'; -import { tRegex, tShape, tString } from './validation-utils.js'; +import { tRegex, tShape, tString, tID } from './validation-utils.js'; import { validHexColorRegex } from '../shared/account-utils.js'; import { onlyOneEmojiRegex } from '../shared/emojis.js'; import type { ENSAvatarDBContent, EmojiAvatarDBContent, ImageAvatarDBContent, UpdateUserAvatarRemoveRequest, UpdateUserAvatarRequest, } from '../types/avatar-types'; const emojiAvatarDBContentValidator: TInterface = tShape({ type: tString('emoji'), emoji: tRegex(onlyOneEmojiRegex), color: tRegex(validHexColorRegex), }); const imageAvatarDBContentValidator: TInterface = tShape({ type: tString('image'), - uploadID: t.String, + uploadID: tID, }); const ensAvatarDBContentValidator: TInterface = tShape({ type: tString('ens'), }); const updateUserAvatarRemoveRequestValidator: TInterface = tShape({ type: tString('remove'), }); const updateUserAvatarRequestValidator: TUnion = t.union([ emojiAvatarDBContentValidator, imageAvatarDBContentValidator, ensAvatarDBContentValidator, updateUserAvatarRemoveRequestValidator, ]); export { emojiAvatarDBContentValidator, imageAvatarDBContentValidator, ensAvatarDBContentValidator, updateUserAvatarRemoveRequestValidator, updateUserAvatarRequestValidator, }; diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js index 9b6e8b654..0f1dfc9cf 100644 --- a/lib/utils/validation-utils.js +++ b/lib/utils/validation-utils.js @@ -1,126 +1,126 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import type { TStructProps, TIrreducible, TRefinement, TEnums, TInterface, TUnion, TType, } from 'tcomb'; import { validEmailRegex, oldValidUsernameRegex, validHexColorRegex, } from '../shared/account-utils.js'; import type { PlatformDetails } from '../types/device-types'; import type { MediaMessageServerDBContent, PhotoMessageServerDBContent, VideoMessageServerDBContent, } from '../types/messages/media'; function tBool(value: boolean): TIrreducible { return t.irreducible('literal bool', x => x === value); } function tString(value: string): TIrreducible { return t.irreducible('literal string', x => x === value); } function tNumber(value: number): TIrreducible { return t.irreducible('literal number', x => x === value); } function tShape(spec: TStructProps): TInterface { return t.interface(spec, { strict: true }); } type TRegex = TRefinement; function tRegex(regex: RegExp): TRegex { return t.refinement(t.String, val => regex.test(val)); } function tNumEnum(nums: $ReadOnlyArray): TRefinement { return t.refinement(t.Number, (input: number) => { for (const num of nums) { if (input === num) { return true; } } return false; }); } const tNull: TIrreducible = t.irreducible('null', x => x === null); const tDate: TRegex = tRegex(/^[0-9]{4}-[0-1][0-9]-[0-3][0-9]$/); const tColor: TRegex = tRegex(validHexColorRegex); // we don't include # char const tPlatform: TEnums = t.enums.of([ 'ios', 'android', 'web', 'windows', 'macos', ]); const tDeviceType: TEnums = t.enums.of(['ios', 'android']); const tPlatformDetails: TInterface = tShape({ platform: tPlatform, codeVersion: t.maybe(t.Number), stateVersion: t.maybe(t.Number), }); const tPassword: TRefinement = t.refinement( t.String, (password: string) => !!password, ); const tCookie: TRegex = tRegex(/^(user|anonymous)=[0-9]+:[0-9a-f]+$/); const tEmail: TRegex = tRegex(validEmailRegex); const tOldValidUsername: TRegex = tRegex(oldValidUsernameRegex); const tID: TRefinement = t.refinement(t.String, (id: string) => !!id); const tMediaMessagePhoto: TInterface = tShape({ type: tString('photo'), - uploadID: t.String, + uploadID: tID, }); const tMediaMessageVideo: TInterface = tShape({ type: tString('video'), - uploadID: t.String, - thumbnailUploadID: t.String, + uploadID: tID, + thumbnailUploadID: tID, }); const tMediaMessageMedia: TUnion = t.union([ tMediaMessagePhoto, tMediaMessageVideo, ]); function assertWithValidator(data: mixed, validator: TType): T { invariant(validator.is(data), "data isn't of type T"); return (data: any); } export { tBool, tString, tNumber, tShape, tRegex, tNumEnum, tNull, tDate, tColor, tPlatform, tDeviceType, tPlatformDetails, tPassword, tCookie, tEmail, tOldValidUsername, tID, tMediaMessagePhoto, tMediaMessageVideo, tMediaMessageMedia, assertWithValidator, };