diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js index ba42df47d..58d4db04b 100644 --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -1,257 +1,262 @@ // @flow import { baseLegalPolicies } from 'lib/facts/policies.js'; import type { Endpoint } from 'lib/types/endpoints.js'; import { updateActivityResponder, threadSetUnreadStatusResponder, } from './responders/activity-responders.js'; import { deviceTokenUpdateResponder } from './responders/device-responders.js'; import { entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, } from './responders/entry-responders.js'; import type { JSONResponder } from './responders/handlers.js'; import { getSessionPublicKeysResponder } from './responders/keys-responders.js'; import { messageReportCreationResponder } from './responders/message-report-responder.js'; import { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, } from './responders/message-responders.js'; import { updateRelationshipsResponder } from './responders/relationship-responders.js'; import { reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, } from './responders/report-responders.js'; import { userSearchResponder } from './responders/search-responders.js'; import { siweNonceResponder } from './responders/siwe-nonce-responders.js'; import { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadFetchMediaResponder, threadJoinResponder, + toggleMessagePinResponder, } from './responders/thread-responders.js'; import { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, } from './responders/user-responders.js'; import { codeVerificationResponder } from './responders/verification-responders.js'; import { uploadDeletionResponder } from './uploads/uploads.js'; const jsonEndpoints: { [id: Endpoint]: JSONResponder } = { create_account: { responder: accountCreationResponder, requiredPolicies: [], }, create_entry: { responder: entryCreationResponder, requiredPolicies: baseLegalPolicies, }, create_error_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_message_report: { responder: messageReportCreationResponder, requiredPolicies: baseLegalPolicies, }, create_multimedia_message: { responder: multimediaMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_reaction_message: { responder: reactionMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, edit_message: { responder: editMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_reports: { responder: reportMultiCreationResponder, requiredPolicies: [], }, create_text_message: { responder: textMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_thread: { responder: threadCreationResponder, requiredPolicies: baseLegalPolicies, }, delete_account: { responder: accountDeletionResponder, requiredPolicies: [], }, delete_entry: { responder: entryDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_thread: { responder: threadDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_upload: { responder: uploadDeletionResponder, requiredPolicies: baseLegalPolicies, }, fetch_entries: { responder: entryFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_entry_revisions: { responder: entryRevisionFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_error_report_infos: { responder: errorReportFetchInfosResponder, requiredPolicies: baseLegalPolicies, }, fetch_messages: { responder: messageFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_thread_media: { responder: threadFetchMediaResponder, requiredPolicies: baseLegalPolicies, }, get_session_public_keys: { responder: getSessionPublicKeysResponder, requiredPolicies: baseLegalPolicies, }, join_thread: { responder: threadJoinResponder, requiredPolicies: baseLegalPolicies, }, leave_thread: { responder: threadLeaveResponder, requiredPolicies: baseLegalPolicies, }, log_in: { responder: logInResponder, requiredPolicies: [], }, log_out: { responder: logOutResponder, requiredPolicies: [], }, policy_acknowledgment: { responder: policyAcknowledgmentResponder, requiredPolicies: [], }, remove_members: { responder: memberRemovalResponder, requiredPolicies: baseLegalPolicies, }, restore_entry: { responder: entryRestorationResponder, requiredPolicies: baseLegalPolicies, }, search_users: { responder: userSearchResponder, requiredPolicies: baseLegalPolicies, }, send_password_reset_email: { responder: sendPasswordResetEmailResponder, requiredPolicies: [], }, send_verification_email: { responder: sendVerificationEmailResponder, requiredPolicies: [], }, set_thread_unread_status: { responder: threadSetUnreadStatusResponder, requiredPolicies: baseLegalPolicies, }, + toggle_message_pin: { + responder: toggleMessagePinResponder, + requiredPolicies: baseLegalPolicies, + }, update_account: { responder: passwordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_activity: { responder: updateActivityResponder, requiredPolicies: baseLegalPolicies, }, update_calendar_query: { responder: calendarQueryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_settings: { responder: updateUserSettingsResponder, requiredPolicies: baseLegalPolicies, }, update_device_token: { responder: deviceTokenUpdateResponder, requiredPolicies: [], }, update_entry: { responder: entryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_password: { responder: oldPasswordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_relationships: { responder: updateRelationshipsResponder, requiredPolicies: baseLegalPolicies, }, update_role: { responder: roleUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_thread: { responder: threadUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_subscription: { responder: userSubscriptionUpdateResponder, requiredPolicies: baseLegalPolicies, }, verify_code: { responder: codeVerificationResponder, requiredPolicies: baseLegalPolicies, }, siwe_nonce: { responder: siweNonceResponder, requiredPolicies: [], }, siwe_auth: { responder: siweAuthResponder, requiredPolicies: [], }, update_user_avatar: { responder: updateUserAvatarResponder, requiredPolicies: baseLegalPolicies, }, }; export { jsonEndpoints }; diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js index 204e72aca..2fb3bd6a0 100644 --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -1,208 +1,224 @@ // @flow import t from 'tcomb'; import type { TUnion, TInterface } from 'tcomb'; 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, threadTypes, } from 'lib/types/thread-types.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { values } from 'lib/utils/objects.js'; import { tShape, tNumEnum, tColor, tPassword, } 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 } from '../utils/validation-utils.js'; const threadDeletionRequestInputValidator = tShape({ threadID: t.String, accountPassword: t.maybe(tPassword), }); async function threadDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadDeletionRequest = input; await validateInput(viewer, threadDeletionRequestInputValidator, request); return await deleteThread(viewer, request); } 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; }), }); async function roleUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: RoleChangeRequest = input; await validateInput(viewer, roleChangeRequestInputValidator, request); return await updateRole(viewer, request); } const removeMembersRequestInputValidator = tShape({ threadID: t.String, memberIDs: t.list(t.String), }); async function memberRemovalResponder( viewer: Viewer, input: any, ): Promise { const request: RemoveMembersRequest = input; await validateInput(viewer, removeMembersRequestInputValidator, request); return await removeMembers(viewer, request); } const leaveThreadRequestInputValidator = tShape({ threadID: t.String, }); async function threadLeaveResponder( viewer: Viewer, input: any, ): Promise { const request: LeaveThreadRequest = input; await validateInput(viewer, leaveThreadRequestInputValidator, request); return await leaveThread(viewer, request); } const updateThreadRequestInputValidator = tShape({ threadID: t.String, 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), 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); return await updateThread(viewer, request); } const threadRequestValidationShape = { name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(t.String), initialMemberIDs: t.maybe(t.list(t.String)), calendarQuery: t.maybe(entryQueryInputValidator), }; const newThreadRequestInputValidator: TUnion = t.union([ tShape({ type: tNumEnum([threadTypes.SIDEBAR]), sourceMessageID: t.String, ...threadRequestValidationShape, }), tShape({ type: tNumEnum([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.PERSONAL, threadTypes.LOCAL, ]), ...threadRequestValidationShape, }), ]); async function threadCreationResponder( viewer: Viewer, input: any, ): Promise { const request: ServerNewThreadRequest = input; await validateInput(viewer, newThreadRequestInputValidator, request); return await createThread(viewer, request, { silentlyFailMembers: request.type === threadTypes.SIDEBAR, }); } const joinThreadRequestInputValidator = tShape({ threadID: t.String, calendarQuery: t.maybe(entryQueryInputValidator), }); async function threadJoinResponder( viewer: Viewer, input: any, ): Promise { const request: ServerThreadJoinRequest = input; await validateInput(viewer, joinThreadRequestInputValidator, request); if (request.calendarQuery) { await verifyCalendarQueryThreadIDs(request.calendarQuery); } return await joinThread(viewer, request); } const threadFetchMediaRequestInputValidator = tShape({ threadID: t.String, limit: t.Number, offset: t.Number, }); async function threadFetchMediaResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadFetchMediaRequest = input; await validateInput(viewer, threadFetchMediaRequestInputValidator, request); return await fetchMediaForThread(viewer, request); } +const toggleMessagePinRequestInputValidator = tShape({ + messageID: t.String, + action: t.enums.of(['pin', 'unpin']), +}); +async function toggleMessagePinResponder( + viewer: Viewer, + input: any, +): Promise { + const request: ToggleMessagePinRequest = input; + await validateInput(viewer, toggleMessagePinRequestInputValidator, request); + await toggleMessagePinForThread(viewer, request); +} + export { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, threadFetchMediaResponder, newThreadRequestInputValidator, + toggleMessagePinResponder, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index a7969897c..4486c185b 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,193 +1,203 @@ // @flow import invariant from 'invariant'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, ClientNewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, ThreadFetchMediaRequest, ThreadFetchMediaResult, + ToggleMessagePinRequest, } from '../types/thread-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); const deleteThread = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, currentAccountPassword: ?string, ) => Promise) => async (threadID, currentAccountPassword) => { const response = await callServerEndpoint('delete_thread', { threadID, accountPassword: currentAccountPassword, }); return { updatesResult: response.updatesResult, }; }; const changeThreadSettingsActionTypes = Object.freeze({ started: 'CHANGE_THREAD_SETTINGS_STARTED', success: 'CHANGE_THREAD_SETTINGS_SUCCESS', failed: 'CHANGE_THREAD_SETTINGS_FAILED', }); const changeThreadSettings = ( callServerEndpoint: CallServerEndpoint, ): ((request: UpdateThreadRequest) => Promise) => async request => { invariant( Object.keys(request.changes).length > 0, 'No changes provided to changeThreadSettings!', ); const response = await callServerEndpoint('update_thread', request); return { threadID: request.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; const removeUsersFromThreadActionTypes = Object.freeze({ started: 'REMOVE_USERS_FROM_THREAD_STARTED', success: 'REMOVE_USERS_FROM_THREAD_SUCCESS', failed: 'REMOVE_USERS_FROM_THREAD_FAILED', }); const removeUsersFromThread = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, memberIDs: $ReadOnlyArray, ) => Promise) => async (threadID, memberIDs) => { const response = await callServerEndpoint('remove_members', { threadID, memberIDs, }); return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; const changeThreadMemberRolesActionTypes = Object.freeze({ started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', }); const changeThreadMemberRoles = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise) => async (threadID, memberIDs, newRole) => { const response = await callServerEndpoint('update_role', { threadID, memberIDs, role: newRole, }); return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; const newThreadActionTypes = Object.freeze({ started: 'NEW_THREAD_STARTED', success: 'NEW_THREAD_SUCCESS', failed: 'NEW_THREAD_FAILED', }); const newThread = ( callServerEndpoint: CallServerEndpoint, ): ((request: ClientNewThreadRequest) => Promise) => async request => { const response = await callServerEndpoint('create_thread', request); return { newThreadID: response.newThreadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, userInfos: response.userInfos, }; }; const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); const joinThread = ( callServerEndpoint: CallServerEndpoint, ): ((request: ClientThreadJoinRequest) => Promise) => async request => { const response = await callServerEndpoint('join_thread', request); const userInfos = values(response.userInfos); return { updatesResult: response.updatesResult, rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, userInfos, }; }; const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); const leaveThread = ( callServerEndpoint: CallServerEndpoint, ): ((threadID: string) => Promise) => async threadID => { const response = await callServerEndpoint('leave_thread', { threadID }); return { updatesResult: response.updatesResult, }; }; const fetchThreadMedia = ( callServerEndpoint: CallServerEndpoint, ): ((request: ThreadFetchMediaRequest) => Promise) => async request => { const response = await callServerEndpoint('fetch_thread_media', request); return response; }; +const toggleMessagePin = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: ToggleMessagePinRequest) => Promise) => + async request => { + await callServerEndpoint('toggle_message_pin', request); + }; + export { deleteThreadActionTypes, deleteThread, changeThreadSettingsActionTypes, changeThreadSettings, removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, newThreadActionTypes, newThread, joinThreadActionTypes, joinThread, leaveThreadActionTypes, leaveThread, fetchThreadMedia, + toggleMessagePin, }; diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js index 7cab2c5c9..ece588e7d 100644 --- a/lib/types/endpoints.js +++ b/lib/types/endpoints.js @@ -1,117 +1,118 @@ // @flow export type APIRequest = { endpoint: Endpoint, input: Object, }; export type SocketAPIHandler = (request: APIRequest) => Promise; export type Endpoint = | HTTPOnlyEndpoint | SocketOnlyEndpoint | HTTPPreferredEndpoint | SocketPreferredEndpoint; // Endpoints that can cause session changes should occur over HTTP, since the // socket code does not currently support changing sessions. In the future they // could be made to work for native, but cookie changes on web require HTTP // since websockets aren't able to Set-Cookie. Note that technically any // endpoint can cause a sessionChange, and in that case the server will close // the socket with a specific error code, and the client will proceed via HTTP. const sessionChangingEndpoints = Object.freeze({ LOG_OUT: 'log_out', DELETE_ACCOUNT: 'delete_account', CREATE_ACCOUNT: 'create_account', LOG_IN: 'log_in', UPDATE_PASSWORD: 'update_password', POLICY_ACKNOWLEDGMENT: 'policy_acknowledgment', }); type SessionChangingEndpoint = $Values; // We do uploads over HTTP as well. This is because Websockets use TCP, which // guarantees ordering. That means that if we start an upload, any messages we // try to send the server after the upload starts will have to wait until the // upload ends. To avoid blocking other messages we upload using HTTP // multipart/form-data. const uploadEndpoints = Object.freeze({ UPLOAD_MULTIMEDIA: 'upload_multimedia', }); type UploadEndpoint = $Values; type HTTPOnlyEndpoint = SessionChangingEndpoint | UploadEndpoint; const socketOnlyEndpoints = Object.freeze({ UPDATE_ACTIVITY: 'update_activity', UPDATE_CALENDAR_QUERY: 'update_calendar_query', }); type SocketOnlyEndpoint = $Values; const socketPreferredEndpoints = Object.freeze({ CREATE_ENTRY: 'create_entry', CREATE_ERROR_REPORT: 'create_error_report', CREATE_MESSAGE_REPORT: 'create_message_report', CREATE_MULTIMEDIA_MESSAGE: 'create_multimedia_message', CREATE_REACTION_MESSAGE: 'create_reaction_message', EDIT_MESSAGE: 'edit_message', CREATE_TEXT_MESSAGE: 'create_text_message', CREATE_THREAD: 'create_thread', DELETE_ENTRY: 'delete_entry', DELETE_THREAD: 'delete_thread', DELETE_UPLOAD: 'delete_upload', FETCH_ENTRIES: 'fetch_entries', FETCH_ENTRY_REVISIONS: 'fetch_entry_revisions', FETCH_ERROR_REPORT_INFOS: 'fetch_error_report_infos', FETCH_MESSAGES: 'fetch_messages', FETCH_THREAD_MEDIA: 'fetch_thread_media', GET_SESSION_PUBLIC_KEYS: 'get_session_public_keys', JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', REMOVE_MEMBERS: 'remove_members', REQUEST_ACCESS: 'request_access', RESTORE_ENTRY: 'restore_entry', SEARCH_USERS: 'search_users', SEND_PASSWORD_RESET_EMAIL: 'send_password_reset_email', SEND_VERIFICATION_EMAIL: 'send_verification_email', SET_THREAD_UNREAD_STATUS: 'set_thread_unread_status', + TOGGLE_MESSAGE_PIN: 'toggle_message_pin', UPDATE_ACCOUNT: 'update_account', UPDATE_USER_SETTINGS: 'update_user_settings', UPDATE_DEVICE_TOKEN: 'update_device_token', UPDATE_ENTRY: 'update_entry', UPDATE_RELATIONSHIPS: 'update_relationships', UPDATE_ROLE: 'update_role', UPDATE_THREAD: 'update_thread', UPDATE_USER_SUBSCRIPTION: 'update_user_subscription', VERIFY_CODE: 'verify_code', SIWE_NONCE: 'siwe_nonce', SIWE_AUTH: 'siwe_auth', UPDATE_USER_AVATAR: 'update_user_avatar', }); type SocketPreferredEndpoint = $Values; const httpPreferredEndpoints = Object.freeze({ CREATE_REPORT: 'create_report', CREATE_REPORTS: 'create_reports', }); type HTTPPreferredEndpoint = $Values; const socketPreferredEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ]); export function endpointIsSocketPreferred(endpoint: Endpoint): boolean { return socketPreferredEndpointSet.has(endpoint); } const socketSafeEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ...Object.values(httpPreferredEndpoints), ]); export function endpointIsSocketSafe(endpoint: Endpoint): boolean { return socketSafeEndpointSet.has(endpoint); } const socketOnlyEndpointSet = new Set(Object.values(socketOnlyEndpoints)); export function endpointIsSocketOnly(endpoint: Endpoint): boolean { return socketOnlyEndpointSet.has(endpoint); }