diff --git a/keyserver/src/creators/account-creator.js b/keyserver/src/creators/account-creator.js index e712d39ce..a2a1fb016 100644 --- a/keyserver/src/creators/account-creator.js +++ b/keyserver/src/creators/account-creator.js @@ -1,300 +1,302 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import ashoat from 'lib/facts/ashoat.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { policyTypes } from 'lib/facts/policies.js'; import { validUsernameRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { RegisterResponse, RegisterRequest, } from 'lib/types/account-types.js'; import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import type { PlatformDetails, DeviceTokenUpdateRequest, } from 'lib/types/device-types.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { SIWESocialProof } from 'lib/types/siwe-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import { isValidEthereumAddress } from 'lib/utils/siwe-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createThread, createPrivateThread, privateThreadDescription, } from './thread-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchLoggedInUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders.js'; import { createNewUserCookie, setNewSession } from '../session/cookies.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { updateThread } from '../updaters/thread-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; const { commbot } = bots; const ashoatMessages = [ 'welcome to Comm!', 'as you inevitably discover bugs, have feature requests, or design ' + 'suggestions, feel free to message them to me in the app.', ]; const privateMessages = [privateThreadDescription]; async function createAccount( viewer: Viewer, request: RegisterRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } - const usernameRegex = hasMinCodeVersion(viewer.platformDetails, 69) + const usernameRegex = hasMinCodeVersion(viewer.platformDetails, { + native: 69, + }) ? validUsernameRegex : oldValidUsernameRegex; if (request.username.search(usernameRegex) === -1) { throw new ServerError('invalid_username'); } const usernameQuery = SQL` SELECT COUNT(id) AS count FROM users WHERE LCASE(username) = LCASE(${request.username}) `; const promises = [dbQuery(usernameQuery)]; const { calendarQuery, signedIdentityKeysBlob } = request; if (calendarQuery) { promises.push(verifyCalendarQueryThreadIDs(calendarQuery)); } const [[usernameResult]] = await Promise.all(promises); if ( reservedUsernamesSet.has(request.username.toLowerCase()) || isValidEthereumAddress(request.username.toLowerCase()) ) { - if (hasMinCodeVersion(viewer.platformDetails, 120)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 120 })) { throw new ServerError('username_reserved'); } else { throw new ServerError('username_taken'); } } if (usernameResult[0].count !== 0) { throw new ServerError('username_taken'); } const hash = bcrypt.hashSync(request.password); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.username, hash, time]; const newUserQuery = SQL` INSERT INTO users(id, username, hash, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, 0); } await Promise.all([ updateThread( createScriptViewer(ashoat.id), { threadID: genesis.id, changes: { newMemberIDs: [id] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ), viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), ]); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer, request.username), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadInfo ? ashoatThreadResult.newThreadInfo.id : ashoatThreadResult.newThreadID; const privateThreadID = privateThreadResult.newThreadInfo ? privateThreadResult.newThreadInfo.id : privateThreadResult.newThreadID; invariant( ashoatThreadID && privateThreadID, 'createThread should return either newThreadInfo or newThreadID', ); let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const privateMessageDatas = privateMessages.map(message => ({ type: messageTypes.TEXT, threadID: privateThreadID, creatorID: commbot.userID, time: messageTime++, text: message, })); const messageDatas = [...ashoatMessageDatas, ...privateMessageDatas]; const [messageInfos, threadsResult, userInfos, currentUserInfo] = await Promise.all([ createMessages(viewer, messageDatas), fetchThreadInfos(viewer), fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawMessageInfos = [ ...ashoatThreadResult.newMessageInfos, ...privateThreadResult.newMessageInfos, ...messageInfos, ]; return { id, rawMessageInfos, currentUserInfo, cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: values(userInfos), }, }; } export type ProcessSIWEAccountCreationRequest = { +address: string, +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +socialProof: SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, }; // Note: `processSIWEAccountCreation(...)` assumes that the validity of // `ProcessSIWEAccountCreationRequest` was checked at call site. async function processSIWEAccountCreation( viewer: Viewer, request: ProcessSIWEAccountCreationRequest, ): Promise { const { calendarQuery, signedIdentityKeysBlob } = request; await verifyCalendarQueryThreadIDs(calendarQuery); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.address, request.address, time]; const newUserQuery = SQL` INSERT INTO users(id, username, ethereum_address, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, socialProof: request.socialProof, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); await setNewSession(viewer, calendarQuery, 0); await Promise.all([ updateThread( createScriptViewer(ashoat.id), { threadID: genesis.id, changes: { newMemberIDs: [id] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ), viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), ]); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer, request.address), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadInfo ? ashoatThreadResult.newThreadInfo.id : ashoatThreadResult.newThreadID; const privateThreadID = privateThreadResult.newThreadInfo ? privateThreadResult.newThreadInfo.id : privateThreadResult.newThreadID; invariant( ashoatThreadID && privateThreadID, 'createThread should return either newThreadInfo or newThreadID', ); let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const privateMessageDatas = privateMessages.map(message => ({ type: messageTypes.TEXT, threadID: privateThreadID, creatorID: commbot.userID, time: messageTime++, text: message, })); const messageDatas = [...ashoatMessageDatas, ...privateMessageDatas]; await Promise.all([createMessages(viewer, messageDatas)]); return id; } export { createAccount, processSIWEAccountCreation }; diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index cb5e9edd9..020fb401e 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,524 +1,524 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/color-utils.js'; import { getThreadTypeParentRequirement } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, } from 'lib/types/thread-types-enum.js'; import { type ServerNewThreadRequest, type NewThreadResponse, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createInitialRolesForNewThread } from './role-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchLatestEditMessageContentByID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { determineThreadAncestry, personalThreadQuery, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, } from '../updaters/thread-permission-updaters.js'; import { joinThread } from '../updaters/thread-updaters.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Shape<{ +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, +silentlyFailMembers: boolean, }>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: ServerNewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; let parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? [...new Set(request.initialMemberIDs)] : null; const ghostMemberIDsFromRequest = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? [...new Set(request.ghostMemberIDs)] : null; const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const parentRequirement = getThreadTypeParentRequirement(threadType); if ( (parentRequirement === 'required' && !parentThreadID) || (parentRequirement === 'disabled' && parentThreadID) ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && request.initialMemberIDs?.length !== 1 ) { throw new ServerError('invalid_parameters'); } const requestParentThreadID = parentThreadID; const confirmParentPermissionPromise = (async () => { if (!requestParentThreadID) { return; } const hasParentPermission = await checkThreadPermission( viewer, requestParentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } })(); // This is a temporary hack until we release actual E2E-encrypted local // conversations. For now we are hosting all root threads on Ashoat's // keyserver, so we set them to the have the Genesis community as their // parent thread. if (!parentThreadID && !threadTypeIsCommunityRoot(threadType)) { parentThreadID = genesis.id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, containingThreadID: threadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !shouldCreateRelationships }, ); if ( !silentlyFailMembers && (Number(initialMemberIDs?.length) < Number(initialMemberIDsFromRequest?.length) || Number(ghostMemberIDs?.length) < Number(ghostMemberIDsFromRequest?.length)) ) { throw new ServerError('invalid_credentials'); } return { initialMemberIDs, ghostMemberIDs }; })(); const checkPromises = {}; checkPromises.confirmParentPermission = confirmParentPermissionPromise; checkPromises.threadAncestry = determineThreadAncestryPromise; checkPromises.validateMembers = validateMembersPromise; if (sourceMessageID) { checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); } const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll(checkPromises); if ( sourceMessage && (sourceMessage.type === messageTypes.REACTION || sourceMessage.type === messageTypes.EDIT_MESSAGE) ) { throw new ServerError('invalid_parameters'); } let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor([ ...(request.initialMemberIDs ?? []), viewer.id, ]); } const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, threadAncestry.containingThreadID, threadAncestry.community, threadAncestry.depth, newRoles.default.id, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) SELECT ${row} WHERE NOT EXISTS (`; query.append(existingThreadQuery).append(SQL`)`); const [result] = await dbQuery(query); if (result.affectedRows === 0) { const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[existingThreadResult]] = await Promise.all([ dbQuery(existingThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant(existingThreadResult.length > 0, 'thread should exist'); const existingThreadID = existingThreadResult[0].id.toString(); let calendarQuery; - if (hasMinCodeVersion(viewer.platformDetails, 87)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 87 })) { invariant(request.calendarQuery, 'calendar query should exist'); calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; } let joinUpdateInfos = []; let userInfos: UserInfos = {}; let newMessageInfos = []; if (threadType !== threadTypes.PERSONAL) { const joinThreadResult = await joinThread(viewer, { threadID: existingThreadID, calendarQuery, }); joinUpdateInfos = joinThreadResult.updatesResult.newUpdates; userInfos = joinThreadResult.userInfos; newMessageInfos = joinThreadResult.rawMessageInfos; } const { viewerUpdates: newUpdates, userInfos: changesetUserInfos } = await getChangesetCommitResultForExistingThread( viewer, existingThreadID, joinUpdateInfos, { calendarQuery, updatesForCurrentSession }, ); userInfos = { ...userInfos, ...changesetUserInfos }; return { newThreadID: existingThreadID, updatesResult: { newUpdates, }, userInfos, newMessageInfos, }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } let initialMemberPromise; if (initialMemberIDs) { initialMemberPromise = changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true, }); } let ghostMemberPromise; if (ghostMemberIDs) { ghostMemberPromise = changeRole(id, ghostMemberIDs, -1); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberPromise, ghostMemberPromise, recalculateThreadPermissions(id), ]); const { membershipRows: creatorMembershipRows, relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipChangeset = new RelationshipChangeset(); relationshipChangeset.addAll(creatorRelationshipChangeset); relationshipChangeset.addAll(recalculateRelationshipChangeset); if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipChangeset: initialMembersRelationshipChangeset, } = initialMembersChangeset; pushAll(membershipRows, initialMembersMembershipRows); relationshipChangeset.addAll(initialMembersRelationshipChangeset); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipChangeset: ghostMembersRelationshipChangeset, } = ghostMembersChangeset; pushAll(membershipRows, ghostMembersMembershipRows); relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates, userInfos } = await commitMembershipChangeset(viewer, changeset, { updatesForCurrentSession, }); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); if (!sourceMessage || sourceMessage.type === messageTypes.SIDEBAR_SOURCE) { throw new ServerError('invalid_parameters'); } let editedSourceMessage = sourceMessage; if (sourceMessageID && sourceMessage.type === messageTypes.TEXT) { const editMessageContent = await fetchLatestEditMessageContentByID( sourceMessageID, ); if (editMessageContent) { editedSourceMessage = { ...sourceMessage, text: editMessageContent.text, }; } } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage: editedSourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if ( parentThreadID && threadType !== threadTypes.SIDEBAR && (parentThreadID !== genesis.id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread( viewer: Viewer, username: string, ): Promise { return createThread( viewer, { type: threadTypes.PRIVATE, name: username, description: privateThreadDescription, ghostMemberIDs: [commbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread, privateThreadDescription }; diff --git a/keyserver/src/deleters/thread-deleters.js b/keyserver/src/deleters/thread-deleters.js index 53b89847b..59af378f9 100644 --- a/keyserver/src/deleters/thread-deleters.js +++ b/keyserver/src/deleters/thread-deleters.js @@ -1,156 +1,156 @@ // @flow import { permissionLookup } from 'lib/permissions/thread-permissions.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadDeletionRequest, type LeaveThreadResult, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchThreadInfos, fetchServerThreadInfos, } from '../fetchers/thread-fetchers.js'; import { fetchThreadPermissionsBlob } from '../fetchers/thread-permission-fetchers.js'; import { fetchUpdateInfoForThreadDeletion } from '../fetchers/update-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import type { Viewer } from '../session/viewer.js'; async function deleteThread( viewer: Viewer, threadDeletionRequest: ThreadDeletionRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { threadID } = threadDeletionRequest; const [permissionsBlob, { threadInfos: serverThreadInfos }] = await Promise.all([ fetchThreadPermissionsBlob(viewer, threadID), fetchServerThreadInfos(SQL`t.id = ${threadID}`), ]); if (!permissionsBlob) { // This should only occur if the first request goes through but the client // never receives the response const [{ updateInfos }, fetchThreadInfoResult] = await Promise.all([ fetchUpdateInfoForThreadDeletion(viewer, threadID), - hasMinCodeVersion(viewer.platformDetails, 62) + hasMinCodeVersion(viewer.platformDetails, { native: 62 }) ? undefined : fetchThreadInfos(viewer), ]); if (fetchThreadInfoResult) { const { threadInfos } = fetchThreadInfoResult; return { threadInfos, updatesResult: { newUpdates: updateInfos } }; } return { updatesResult: { newUpdates: updateInfos } }; } const hasPermission = permissionLookup( permissionsBlob, threadPermissions.DELETE_THREAD, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } await rescindPushNotifs( SQL`n.thread = ${threadID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ); // TODO: if org, delete all descendant threads as well. make sure to warn user // TODO: handle descendant thread permission update correctly. // thread-permission-updaters should be used for descendant threads. const query = SQL` DELETE t, ic, d, id, e, ie, re, ire, mm, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids ic ON ic.id = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN memberships mm ON mm.thread = t.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON (up.container = ms.id OR up.container = t.id) LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE t.id = ${threadID} `; const serverThreadInfo = serverThreadInfos[threadID]; const time = Date.now(); const updateDatas = []; for (const memberInfo of serverThreadInfo.members) { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID: memberInfo.id, time, threadID, }); } const [{ viewerUpdates }] = await Promise.all([ createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'return' }), dbQuery(query), ]); - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { updatesResult: { newUpdates: viewerUpdates } }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } async function deleteInaccessibleThreads(): Promise { // A thread is considered "inaccessible" if it has no membership rows. Note // that membership rows exist whenever a user can see a thread, even if they // are not technically a member (in which case role=0). For now, we're also // excluding threads with children, since to properly delete those we would // need to update their parent_thread_id, and possibly change their type. await dbQuery(SQL` DELETE t, i, m2, d, id, e, ie, re, ire, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids i ON i.id = t.id LEFT JOIN memberships m1 ON m1.thread = t.id AND m1.role > -1 LEFT JOIN threads c ON c.parent_thread_id = t.id LEFT JOIN memberships m2 ON m2.thread = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON (up.container = ms.id OR up.container = t.id) LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE m1.thread IS NULL AND c.id IS NULL `); } export { deleteThread, deleteInaccessibleThreads }; diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js index 9ce200bac..8eaa64ee0 100644 --- a/keyserver/src/fetchers/message-fetchers.js +++ b/keyserver/src/fetchers/message-fetchers.js @@ -1,916 +1,918 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { getNotifCollapseKey } from 'lib/shared/notif-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { messageTypes, type MessageType, assertMessageType, } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawComposableMessageInfo, type RawRobotextMessageInfo, type EditMessageContent, type MessageSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, defaultMaxMessageAge, type FetchPinnedMessagesRequest, type FetchPinnedMessagesResult, isMessageSidebarSourceReactionOrEdit, } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { constructMediaFromMediaMessageContentsAndUploadRows, imagesFromRow, } from './upload-fetchers.js'; import { dbQuery, SQL, mergeOrConditions, mergeAndConditions, } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { PushInfo } from '../push/send.js'; import type { Viewer } from '../session/viewer.js'; import { creationString, localIDFromCreationString, } from '../utils/idempotent.js'; export type CollapsableNotifInfo = { collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], }; export type FetchCollapsableNotifsResult = { [userID: string]: CollapsableNotifInfo[], }; const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (const userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) { const rawMessageInfo = pushInfo[userID].messageInfos[i]; const messageData = pushInfo[userID].messageDatas[i]; const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (const collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return usersToCollapsableNotifInfo; } const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC, m.id DESC`); const [collapseResult] = await dbQuery(collapseQuery); const rowsByUser = new Map(); for (const row of collapseResult) { const user = row.user.toString(); const currentRowsForUser = rowsByUser.get(user); if (currentRowsForUser) { currentRowsForUser.push(row); } else { rowsByUser.set(user, [row]); } } const derivedMessages = await fetchDerivedMessages(collapseResult); for (const userRows of rowsByUser.values()) { const messages = parseMessageSQLResult(userRows, derivedMessages); for (const message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } } for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (const collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return usersToCollapsableNotifInfo; } type MessageSQLResult = $ReadOnlyArray<{ rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, }>; function parseMessageSQLResult( rows: $ReadOnlyArray, derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, viewer?: Viewer, ): MessageSQLResult { const rowsByID = new Map(); for (const row of rows) { const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (const messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows( messageRows, viewer, derivedMessages, ); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map(row => row.id.toString()); console.warn( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rawRows: $ReadOnlyArray, viewer?: Viewer, derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, ): ?RawMessageInfo { const rows = rawRows.map(row => ({ ...row, subthread_permissions: JSON.parse(row.subthread_permissions), })); const type = mostRecentRowType(rows); const messageSpec = messageSpecs[type]; if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { let media; if (type === messageTypes.MULTIMEDIA) { const mediaMessageContents = JSON.parse(rows[0].content); media = constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, rows, ); } else { media = rows.filter(row => row.uploadID).map(imagesFromRow); } const [row] = rows; const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromServerDBRow, `multimedia message spec should have rawMessageInfoFromServerDBRow`, ); return messageSpec.rawMessageInfoFromServerDBRow(row, { media, derivedMessages, localID, }); } const row = assertSingleRow(rows); const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromServerDBRow, `message spec ${type} should have rawMessageInfoFromServerDBRow`, ); return messageSpec.rawMessageInfoFromServerDBRow(row, { derivedMessages, localID, }); } async function fetchMessageInfos( viewer: Viewer, criteria: MessageSelectionCriteria, numberPerThread: number, ): Promise { const { sqlClause: selectionClause, timeFilterData } = parseMessageSelectionCriteria(viewer, criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` WITH thread_window AS ( SELECT m.id, m.thread AS threadID, m.user AS creatorID, m.target_message as targetMessageID, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, ROW_NUMBER() OVER ( PARTITION BY threadID ORDER BY m.time DESC, m.id DESC ) n FROM messages m LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(selectionClause); query.append(SQL` ) SELECT tw.*, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM thread_window tw LEFT JOIN uploads up ON up.container = tw.id WHERE tw.n <= ${numberPerThread} ORDER BY tw.threadID, tw.time DESC, tw.id DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = await parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (const message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (const [threadID, messageCount] of threadToMessageCount) { // If we matched the exact amount we limited to, we're probably truncating // our result set. By setting TRUNCATED here, we tell the client that the // result set might not be continguous with what's already in their // MessageStore. More details about TRUNCATED can be found in // lib/types/message-types.js if (messageCount >= numberPerThread) { // We won't set TRUNCATED if a cursor was specified for a given thread, // since then the result is guaranteed to be contiguous with what the // client has if (criteria.threadCursors && criteria.threadCursors[threadID]) { truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } continue; } const hasTimeFilter = hasTimeFilterForThread(timeFilterData, threadID); if (!hasTimeFilter) { // If there is no time filter for a given thread, and there are fewer // messages returned than the max we queried for a given thread, we can // conclude that our result set includes all messages for that thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const rawMessageInfo of rawMessageInfos) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus !== null && truncationStatus !== undefined) { continue; } const hasTimeFilter = hasTimeFilterForThread(timeFilterData, threadID); if (!hasTimeFilter) { // If there is no time filter for a given thread, and zero messages were // returned, we can conclude that this thread has zero messages. This is // a case of database corruption that should not be possible, but likely // we have some threads like this on prod (either due to some transient // issues or due to old buggy code) truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else { // If this thread was explicitly queried for, and we got no results, but // we can't conclude that it's EXHAUSTIVE, then we'll set to UNCHANGED. truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function hasTimeFilterForThread( timeFilterData: TimeFilterData, threadID: string, ) { if (timeFilterData.timeFilter === 'ALL') { return true; } else if (timeFilterData.timeFilter === 'NONE') { return false; } else if (timeFilterData.timeFilter === 'ALL_EXCEPT_EXCLUDED') { return !timeFilterData.excludedFromTimeFilter.has(threadID); } else { invariant( false, `unrecognized timeFilter type ${timeFilterData.timeFilter}`, ); } } type TimeFilterData = | { +timeFilter: 'ALL' | 'NONE' } | { +timeFilter: 'ALL_EXCEPT_EXCLUDED', +excludedFromTimeFilter: $ReadOnlySet, }; type ParsedMessageSelectionCriteria = { +sqlClause: SQLStatementType, +timeFilterData: TimeFilterData, }; function parseMessageSelectionCriteria( viewer: Viewer, criteria: MessageSelectionCriteria, ): ParsedMessageSelectionCriteria { const minMessageTime = Date.now() - defaultMaxMessageAge; - const shouldApplyTimeFilter = hasMinCodeVersion(viewer.platformDetails, 130); + const shouldApplyTimeFilter = hasMinCodeVersion(viewer.platformDetails, { + native: 130, + }); let globalTimeFilter; if (criteria.newerThan) { globalTimeFilter = SQL`m.time > ${criteria.newerThan}`; } else if (!criteria.threadCursors && shouldApplyTimeFilter) { globalTimeFilter = SQL`m.time > ${minMessageTime}`; } const threadConditions = []; if ( criteria.joinedThreads === true && shouldApplyTimeFilter && !globalTimeFilter ) { threadConditions.push(SQL`(mm.role > 0 AND m.time > ${minMessageTime})`); } else if (criteria.joinedThreads === true) { threadConditions.push(SQL`mm.role > 0`); } if (criteria.threadCursors) { for (const threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { threadConditions.push( SQL`(m.thread = ${threadID} AND m.id < ${cursor})`, ); } else { threadConditions.push(SQL`m.thread = ${threadID}`); } } } if (threadConditions.length === 0) { throw new ServerError('internal_error'); } const threadClause = mergeOrConditions(threadConditions); let timeFilterData; if (globalTimeFilter) { timeFilterData = { timeFilter: 'ALL' }; } else if (!shouldApplyTimeFilter) { timeFilterData = { timeFilter: 'NONE' }; } else { invariant( criteria.threadCursors, 'ALL_EXCEPT_EXCLUDED should correspond to threadCursors being set', ); const excludedFromTimeFilter = new Set(Object.keys(criteria.threadCursors)); timeFilterData = { timeFilter: 'ALL_EXCEPT_EXCLUDED', excludedFromTimeFilter, }; } const conditions = [globalTimeFilter, threadClause].filter(Boolean); const sqlClause = mergeAndConditions(conditions); return { sqlClause, timeFilterData }; } function messageSelectionCriteriaToInitialTruncationStatuses( criteria: MessageSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (const threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchMessageInfosSince( viewer: Viewer, criteria: MessageSelectionCriteria, maxNumberPerThread: number, ): Promise { const { sqlClause: selectionClause } = parseMessageSelectionCriteria( viewer, criteria, ); const truncationStatuses = messageSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(selectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC, m.id DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = await parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (const message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): FetchMessageInfosResult { const truncationStatuses = {}; for (const rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchMessageRowsByIDs(messageIDs: $ReadOnlyArray) { const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.id IN (${messageIDs}) `; const [result] = await dbQuery(query); return result; } async function fetchPinnedMessageInfos( viewer: Viewer, request: FetchPinnedMessagesRequest, ): Promise { // The only message types that can be pinned are 0, 14, and 15 // (text, images, and multimedia), so we don't need to worry about // an admin pinning a message about creating a secret subchannel. This is // why we don't check subthread permissions (as opposed to other queries). const messageRowsQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, NULL AS subthread_permissions, u.id AS uploadID, u.type AS uploadType, u.secret AS uploadSecret, u.extra AS uploadExtra FROM messages m LEFT JOIN uploads u ON u.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.pinned = 1 AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY m.pin_time DESC `; const [messageRows] = await dbQuery(messageRowsQuery); if (messageRows.length === 0) { return { pinnedMessages: [] }; } const pinnedAndRelatedMessages = await rawMessageInfoForRowsAndRelatedMessages(messageRows, viewer); const shimmedPinnedRawMessageInfos = shimUnsupportedRawMessageInfos( pinnedAndRelatedMessages, viewer.platformDetails, ); return { pinnedMessages: shimmedPinnedRawMessageInfos, }; } async function fetchDerivedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise< $ReadOnlyMap, > { const requiredIDs = new Set(); for (const row of rows) { if (row.type === messageTypes.SIDEBAR_SOURCE) { const content = JSON.parse(row.content); requiredIDs.add(content.sourceMessageID); } else if (row.type === messageTypes.TOGGLE_PIN) { const content = JSON.parse(row.content); requiredIDs.add(content.targetMessageID); } } const messagesByID = new Map< string, RawComposableMessageInfo | RawRobotextMessageInfo, >(); if (requiredIDs.size === 0) { return messagesByID; } const [result, edits] = await Promise.all([ fetchMessageRowsByIDs([...requiredIDs]), fetchLatestEditMessageContentByIDs([...requiredIDs]), ]); const messages = await parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { let { rawMessageInfo } = message; invariant( !isMessageSidebarSourceReactionOrEdit(rawMessageInfo), 'SIDEBAR_SOURCE or TOGGLE_PIN should not point to a ' + 'SIDEBAR_SOURCE, REACTION or EDIT_MESSAGE', ); if (rawMessageInfo.id) { const editedContent = edits.get(rawMessageInfo.id); if (editedContent && rawMessageInfo.type === messageTypes.TEXT) { rawMessageInfo = { ...rawMessageInfo, text: editedContent.text, }; } invariant(rawMessageInfo.id, 'rawMessageInfo.id should not be null'); messagesByID.set(rawMessageInfo.id, rawMessageInfo); } } return messagesByID; } async function fetchMessageInfoByID( viewer?: Viewer, messageID: string, ): Promise { const result = await fetchMessageRowsByIDs([messageID]); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchThreadMessagesCount(threadID: string): Promise { const query = SQL` SELECT COUNT(*) AS count FROM messages WHERE thread = ${threadID} `; const [result] = await dbQuery(query); return result[0].count; } async function fetchLatestEditMessageContentByIDs( messageIDs: $ReadOnlyArray, ): Promise<$ReadOnlyMap> { const latestEditedMessageQuery = SQL` SELECT m.id, ( SELECT m2.content FROM messages m2 WHERE m.id = m2.target_message AND m.thread = m2.thread AND m2.type = ${messageTypes.EDIT_MESSAGE} ORDER BY time DESC, id DESC LIMIT 1 ) content FROM messages m WHERE m.id IN(${messageIDs}) `; const [result] = await dbQuery(latestEditedMessageQuery); const latestContentByID = new Map(); for (const row of result) { if (!row.content) { continue; } const content = JSON.parse(row.content); latestContentByID.set(row.id.toString(), content); } return latestContentByID; } async function fetchLatestEditMessageContentByID( messageID: string, ): Promise { const result = await fetchLatestEditMessageContentByIDs([messageID]); const content = result.get(messageID); return content; } async function fetchRelatedMessages( viewer?: Viewer, messages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, ): Promise<$ReadOnlyArray> { if (messages.size === 0) { return []; } const originalMessageIDs = [...messages.keys()]; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.target_message IN (${originalMessageIDs}) AND ( m.type = ${messageTypes.SIDEBAR_SOURCE} OR m.type = ${messageTypes.REACTION} ) UNION SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m2 INNER JOIN messages m ON m.id = ( SELECT m3.id FROM messages m3 WHERE m3.target_message = m2.id AND m3.thread = m2.thread AND m3.type = ${messageTypes.EDIT_MESSAGE} ORDER BY time DESC, id DESC LIMIT 1 ) LEFT JOIN uploads up ON up.container = m2.id LEFT JOIN memberships stm ON m2.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m2.content AND stm.user = m2.user WHERE m2.id IN (${originalMessageIDs}) `; const [resultRows] = await dbQuery(query); if (resultRows.length === 0) { return []; } const SQLResult = await parseMessageSQLResult(resultRows, messages, viewer); return SQLResult.map(item => item.rawMessageInfo); } async function rawMessageInfoForRowsAndRelatedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise<$ReadOnlyArray> { const parsedResults = await parseMessageSQLResult(rows, new Map(), viewer); const rawMessageInfoMap = new Map< string, RawComposableMessageInfo | RawRobotextMessageInfo, >(); for (const message of parsedResults) { const { rawMessageInfo } = message; if (isMessageSidebarSourceReactionOrEdit(rawMessageInfo)) { continue; } invariant(rawMessageInfo.id, 'rawMessageInfo.id should not be null'); rawMessageInfoMap.set(rawMessageInfo.id, rawMessageInfo); } const rawMessageInfos = parsedResults.map(item => item.rawMessageInfo); const rawRelatedMessageInfos = await fetchRelatedMessages( viewer, rawMessageInfoMap, ); return [...rawMessageInfos, ...rawRelatedMessageInfos]; } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, fetchThreadMessagesCount, fetchLatestEditMessageContentByID, fetchPinnedMessageInfos, fetchRelatedMessages, rawMessageInfoForRowsAndRelatedMessages, }; diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js index f8eb31f98..3d4488b0c 100644 --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -1,305 +1,317 @@ // @flow import invariant from 'invariant'; import { getAllThreadPermissions } from 'lib/permissions/thread-permissions.js'; import { rawThreadInfoFromServerThreadInfo, getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { AvatarDBContent, ClientAvatar } from 'lib/types/avatar-types.js'; import type { RawMessageInfo, MessageInfo } from 'lib/types/message-types.js'; import { threadTypes, type ThreadType } from 'lib/types/thread-types-enum.js'; import { type RawThreadInfo, type ServerThreadInfo, } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { getUploadURL } from './upload-fetchers.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { Viewer } from '../session/viewer.js'; type FetchServerThreadInfosResult = { +threadInfos: { +[id: string]: ServerThreadInfo }, }; async function fetchServerThreadInfos( condition?: SQLStatementType, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const rolesQuery = SQL` SELECT t.id, t.default_role, r.id AS role, r.name, r.permissions FROM threads t LEFT JOIN roles r ON r.thread = t.id `.append(whereClause); const threadsQuery = SQL` SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, t.community, t.depth, t.color, t.description, t.type, t.creation_time, t.source_message, t.replies_count, t.avatar, t.pinned_count, m.user, m.role, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender, up.id AS upload_id, up.secret AS upload_secret FROM threads t LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0 LEFT JOIN uploads up ON up.container = t.id ` .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [[threadsResult], [rolesResult]] = await Promise.all([ dbQuery(threadsQuery), dbQuery(rolesQuery), ]); const threadInfos = {}; for (const threadsRow of threadsResult) { const threadID = threadsRow.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: threadsRow.type, name: threadsRow.name ? threadsRow.name : '', description: threadsRow.description ? threadsRow.description : '', color: threadsRow.color, creationTime: threadsRow.creation_time, parentThreadID: threadsRow.parent_thread_id ? threadsRow.parent_thread_id.toString() : null, containingThreadID: threadsRow.containing_thread_id ? threadsRow.containing_thread_id.toString() : null, depth: threadsRow.depth, community: threadsRow.community ? threadsRow.community.toString() : null, members: [], roles: {}, repliesCount: threadsRow.replies_count, pinnedCount: threadsRow.pinned_count, }; if (threadsRow.avatar) { const avatar: AvatarDBContent = JSON.parse(threadsRow.avatar); let clientAvatar: ?ClientAvatar; if (avatar && avatar.type !== 'image') { clientAvatar = avatar; } else if ( avatar && avatar.type === 'image' && threadsRow.upload_id && threadsRow.upload_secret ) { const uploadID = threadsRow.upload_id.toString(); invariant( uploadID === avatar.uploadID, `uploadID of upload should match uploadID of image avatar`, ); clientAvatar = { type: 'image', uri: getUploadURL(uploadID, threadsRow.upload_secret), }; } threadInfos[threadID] = { ...threadInfos[threadID], avatar: clientAvatar, }; } } const sourceMessageID = threadsRow.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } if (threadsRow.user) { const userID = threadsRow.user.toString(); const allPermissions = getAllThreadPermissions( JSON.parse(threadsRow.permissions), threadID, ); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: threadsRow.role ? threadsRow.role.toString() : null, subscription: JSON.parse(threadsRow.subscription), unread: threadsRow.role ? !!threadsRow.unread : null, isSender: !!threadsRow.sender, }); } } for (const rolesRow of rolesResult) { const threadID = rolesRow.id.toString(); if (!rolesRow.role) { continue; } const role = rolesRow.role.toString(); if (!threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: rolesRow.name, permissions: JSON.parse(rolesRow.permissions), isDefault: role === rolesRow.default_role.toString(), }; } } return { threadInfos }; } export type FetchThreadInfosResult = { +threadInfos: { +[id: string]: RawThreadInfo }, }; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatementType, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } const shimCommunityRoot = { [threadTypes.COMMUNITY_ROOT]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD]: threadTypes.COMMUNITY_OPEN_SUBTHREAD, [threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.GENESIS]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, }; function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; - const codeVersionBelow70 = !hasMinCodeVersion(viewer.platformDetails, 70); - const codeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); - const codeVersionBelow102 = !hasMinCodeVersion(viewer.platformDetails, 102); - const codeVersionBelow104 = !hasMinCodeVersion(viewer.platformDetails, 104); - const codeVersionBelow209 = !hasMinCodeVersion(viewer.platformDetails, 209); - const codeVersionBelow213 = !hasMinCodeVersion(viewer.platformDetails, 213); + const codeVersionBelow70 = !hasMinCodeVersion(viewer.platformDetails, { + native: 70, + }); + const codeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, { + native: 87, + }); + const codeVersionBelow102 = !hasMinCodeVersion(viewer.platformDetails, { + native: 102, + }); + const codeVersionBelow104 = !hasMinCodeVersion(viewer.platformDetails, { + native: 104, + }); + const codeVersionBelow209 = !hasMinCodeVersion(viewer.platformDetails, { + native: 209, + }); + const codeVersionBelow213 = !hasMinCodeVersion(viewer.platformDetails, { + native: 213, + }); const threadInfos = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { includeVisibilityRules: codeVersionBelow70, filterMemberList: codeVersionBelow70, shimThreadTypes: codeVersionBelow87 ? shimCommunityRoot : null, hideThreadStructure: codeVersionBelow102, filterDetailedThreadEditPermissions: codeVersionBelow104, filterThreadEditAvatarPermission: codeVersionBelow213, excludePinInfo: codeVersionBelow209, }, ); if (threadInfo) { threadInfos[threadID] = threadInfo; } } return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; for (const row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } type ThreadAncestry = { +containingThreadID: ?string, +community: ?string, +depth: number, }; async function determineThreadAncestry( parentThreadID: ?string, threadType: ThreadType, ): Promise { if (!parentThreadID) { return { containingThreadID: null, community: null, depth: 0 }; } const parentThreadInfos = await fetchServerThreadInfos( SQL`t.id = ${parentThreadID}`, ); const parentThreadInfo = parentThreadInfos.threadInfos[parentThreadID]; if (!parentThreadInfo) { throw new ServerError('invalid_parameters'); } const containingThreadID = getContainingThreadID( parentThreadInfo, threadType, ); const community = getCommunity(parentThreadInfo); const depth = parentThreadInfo.depth + 1; return { containingThreadID, community, depth }; } function personalThreadQuery( firstMemberID: string, secondMemberID: string, ): SQLStatementType { return SQL` SELECT t.id FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${firstMemberID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${secondMemberID} WHERE t.type = ${threadTypes.PERSONAL} AND m1.role > 0 AND m2.role > 0 `; } async function fetchPersonalThreadID( viewerID: string, otherMemberID: string, ): Promise { const query = personalThreadQuery(viewerID, otherMemberID); const [threads] = await dbQuery(query); return threads[0]?.id.toString(); } async function serverThreadInfoFromMessageInfo( message: RawMessageInfo | MessageInfo, ): Promise { const threadID = message.threadID; const threads = await fetchServerThreadInfos(SQL`t.id = ${threadID}`); return threads.threadInfos[threadID]; } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, determineThreadAncestry, personalThreadQuery, fetchPersonalThreadID, serverThreadInfoFromMessageInfo, }; diff --git a/keyserver/src/fetchers/user-fetchers.js b/keyserver/src/fetchers/user-fetchers.js index 70c25b0bd..3d0e80806 100644 --- a/keyserver/src/fetchers/user-fetchers.js +++ b/keyserver/src/fetchers/user-fetchers.js @@ -1,420 +1,421 @@ // @flow import invariant from 'invariant'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { AvatarDBContent, ClientAvatar } from 'lib/types/avatar-types.js'; import { undirectedStatus, directedStatus, userRelationshipStatus, } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { communityThreadTypes } from 'lib/types/thread-types-enum.js'; import type { UserInfos, CurrentUserInfo, OldCurrentUserInfo, LoggedInUserInfo, OldLoggedInUserInfo, GlobalUserInfo, } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { getUploadURL } from './upload-fetchers.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; async function fetchUserInfos( userIDs: string[], ): Promise<{ [id: string]: GlobalUserInfo }> { if (userIDs.length <= 0) { return {}; } const query = SQL` SELECT u.id, u.username, u.avatar, up.id AS upload_id, up.secret AS upload_secret FROM users u LEFT JOIN uploads up ON up.container = u.id WHERE u.id IN (${userIDs}) `; const [result] = await dbQuery(query); const userInfos = {}; for (const row of result) { const id = row.id.toString(); const avatar: ?AvatarDBContent = row.avatar ? JSON.parse(row.avatar) : null; let clientAvatar: ?ClientAvatar; if (avatar && avatar.type !== 'image') { clientAvatar = avatar; } else if ( avatar && avatar.type === 'image' && row.upload_id && row.upload_secret ) { const uploadID = row.upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); clientAvatar = { type: 'image', uri: getUploadURL(uploadID, row.upload_secret), }; } userInfos[id] = clientAvatar ? { id, username: row.username, avatar: clientAvatar, } : { id, username: row.username, }; } for (const userID of userIDs) { if (!userInfos[userID]) { userInfos[userID] = { id: userID, username: null, }; } } return userInfos; } async function fetchKnownUserInfos( viewer: Viewer, userIDs?: $ReadOnlyArray, ): Promise { if (!viewer.loggedIn) { return {}; } if (userIDs && userIDs.length === 0) { return {}; } const query = SQL` SELECT ru.user1, ru.user2, u.username, u.avatar, ru.status AS undirected_status, rd1.status AS user1_directed_status, rd2.status AS user2_directed_status, up.id AS upload_id, up.secret AS upload_secret FROM relationships_undirected ru LEFT JOIN relationships_directed rd1 ON rd1.user1 = ru.user1 AND rd1.user2 = ru.user2 LEFT JOIN relationships_directed rd2 ON rd2.user1 = ru.user2 AND rd2.user2 = ru.user1 LEFT JOIN users u ON u.id != ${viewer.userID} AND (u.id = ru.user1 OR u.id = ru.user2) LEFT JOIN uploads up ON up.container != ${viewer.userID} AND (up.container = ru.user1 OR up.container = ru.user2) `; if (userIDs) { query.append(SQL` WHERE (ru.user1 = ${viewer.userID} AND ru.user2 IN (${userIDs})) OR (ru.user1 IN (${userIDs}) AND ru.user2 = ${viewer.userID}) `); } else { query.append(SQL` WHERE ru.user1 = ${viewer.userID} OR ru.user2 = ${viewer.userID} `); } query.append(SQL` UNION SELECT u.id AS user1, NULL AS user2, u.username, u.avatar, CAST(NULL AS UNSIGNED) AS undirected_status, CAST(NULL AS UNSIGNED) AS user1_directed_status, CAST(NULL AS UNSIGNED) AS user2_directed_status, up.id AS upload_id, up.secret AS upload_secret FROM users u LEFT JOIN uploads up ON up.container = u.id WHERE u.id = ${viewer.userID} `); const [result] = await dbQuery(query); const userInfos = {}; for (const row of result) { const user1 = row.user1.toString(); const user2 = row.user2 ? row.user2.toString() : null; const id = user1 === viewer.userID && user2 ? user2 : user1; const avatar: ?AvatarDBContent = row.avatar ? JSON.parse(row.avatar) : null; let clientAvatar: ?ClientAvatar; if (avatar && avatar.type !== 'image') { clientAvatar = avatar; } else if ( avatar && avatar.type === 'image' && row.upload_id && row.upload_secret ) { const uploadID = row.upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); clientAvatar = { type: 'image', uri: getUploadURL(uploadID, row.upload_secret), }; } const userInfo = clientAvatar ? { id, username: row.username, avatar: clientAvatar, } : { id, username: row.username, }; if (!user2) { userInfos[id] = userInfo; continue; } let viewerDirectedStatus; let targetDirectedStatus; if (user1 === viewer.userID) { viewerDirectedStatus = row.user1_directed_status; targetDirectedStatus = row.user2_directed_status; } else { viewerDirectedStatus = row.user2_directed_status; targetDirectedStatus = row.user1_directed_status; } const viewerBlockedTarget = viewerDirectedStatus === directedStatus.BLOCKED; const targetBlockedViewer = targetDirectedStatus === directedStatus.BLOCKED; const friendshipExists = row.undirected_status === undirectedStatus.FRIEND; const viewerRequestedTargetFriendship = viewerDirectedStatus === directedStatus.PENDING_FRIEND; const targetRequestedViewerFriendship = targetDirectedStatus === directedStatus.PENDING_FRIEND; let relationshipStatus; if (viewerBlockedTarget && targetBlockedViewer) { relationshipStatus = userRelationshipStatus.BOTH_BLOCKED; } else if (targetBlockedViewer) { relationshipStatus = userRelationshipStatus.BLOCKED_VIEWER; } else if (viewerBlockedTarget) { relationshipStatus = userRelationshipStatus.BLOCKED_BY_VIEWER; } else if (friendshipExists) { relationshipStatus = userRelationshipStatus.FRIEND; } else if (targetRequestedViewerFriendship) { relationshipStatus = userRelationshipStatus.REQUEST_RECEIVED; } else if (viewerRequestedTargetFriendship) { relationshipStatus = userRelationshipStatus.REQUEST_SENT; } userInfos[id] = userInfo; if (relationshipStatus) { userInfos[id].relationshipStatus = relationshipStatus; } if (relationshipStatus && !row.username) { console.warn( `user ${viewer.userID} has ${relationshipStatus} relationship with ` + `anonymous user ${id}`, ); } } return userInfos; } async function verifyUserIDs( userIDs: $ReadOnlyArray, ): Promise { if (userIDs.length === 0) { return []; } const query = SQL`SELECT id FROM users WHERE id IN (${userIDs})`; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function verifyUserOrCookieIDs( ids: $ReadOnlyArray, ): Promise { if (ids.length === 0) { return []; } const query = SQL` SELECT id FROM users WHERE id IN (${ids}) UNION SELECT id FROM cookies WHERE id IN (${ids}) `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function fetchCurrentUserInfo( viewer: Viewer, ): Promise { if (!viewer.loggedIn) { return ({ id: viewer.cookieID, anonymous: true }: CurrentUserInfo); } const currentUserInfo = await fetchLoggedInUserInfo(viewer); return currentUserInfo; } async function fetchLoggedInUserInfo( viewer: Viewer, ): Promise { const userQuery = SQL` SELECT u.id, u.username, u.avatar, up.id AS upload_id, up.secret AS upload_secret FROM users u LEFT JOIN uploads up ON up.container = u.id WHERE u.id = ${viewer.userID} `; const settingsQuery = SQL` SELECT name, data FROM settings WHERE user = ${viewer.userID} `; const [[userResult], [settingsResult]] = await Promise.all([ dbQuery(userQuery), dbQuery(settingsQuery), ]); const [userRow] = userResult; - const stillExpectsEmailFields = !hasMinCodeVersion( - viewer.platformDetails, - 87, - ); + const stillExpectsEmailFields = !hasMinCodeVersion(viewer.platformDetails, { + native: 87, + }); if (!userRow) { throw new ServerError('unknown_error'); } const id = userRow.id.toString(); const { username, upload_id, upload_secret } = userRow; if (stillExpectsEmailFields) { return { id, username, email: 'removed from DB', emailVerified: true, }; } let loggedInUserInfo: LoggedInUserInfo = { id, username, }; const avatar: ?AvatarDBContent = userRow.avatar ? JSON.parse(userRow.avatar) : null; let clientAvatar: ?ClientAvatar; if (avatar && avatar.type !== 'image') { clientAvatar = avatar; } else if (avatar && avatar.type === 'image' && upload_id && upload_secret) { const uploadID = upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); clientAvatar = { type: 'image', uri: getUploadURL(uploadID, upload_secret), }; } if (avatar) { loggedInUserInfo = { ...loggedInUserInfo, avatar: clientAvatar }; } - const featureGateSettings = !hasMinCodeVersion(viewer.platformDetails, 1000); + const featureGateSettings = !hasMinCodeVersion(viewer.platformDetails, { + native: 1000, + }); if (featureGateSettings) { return loggedInUserInfo; } const settings = settingsResult.reduce((prev, curr) => { prev[curr.name] = curr.data; return prev; }, {}); loggedInUserInfo = { ...loggedInUserInfo, settings }; return loggedInUserInfo; } async function fetchAllUserIDs(): Promise { const query = SQL`SELECT id FROM users`; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function fetchUsername(id: string): Promise { const query = SQL`SELECT username FROM users WHERE id = ${id}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return row.username; } async function fetchKeyserverAdminID(): Promise { const changeRoleExtractString = `$.${threadPermissions.CHANGE_ROLE}`; const query = SQL` SELECT m.user FROM memberships m INNER JOIN roles r ON m.role = r.id INNER JOIN threads t ON r.thread = t.id WHERE r.name = "Admins" AND t.type IN (${communityThreadTypes}) AND JSON_EXTRACT(r.permissions, ${changeRoleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } if (result.length > 1) { console.warn('more than one community admin found'); } return result[0].user; } async function fetchUserIDForEthereumAddress( address: string, ): Promise { const query = SQL` SELECT id FROM users WHERE LCASE(ethereum_address) = LCASE(${address}) `; const [result] = await dbQuery(query); return result.length === 0 ? null : result[0].id.toString(); } export { fetchUserInfos, fetchLoggedInUserInfo, verifyUserIDs, verifyUserOrCookieIDs, fetchCurrentUserInfo, fetchAllUserIDs, fetchUsername, fetchKnownUserInfos, fetchKeyserverAdminID, fetchUserIDForEthereumAddress, }; diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index 0de1188b4..bca8445c2 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,806 +1,806 @@ // @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 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 { verifyClientSupported } from '../session/version.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: 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: mixed, ): Promise { const request = await validateInput( viewer, subscriptionUpdateRequestInputValidator, input, ); const threadSubscription = await userSubscriptionUpdater(viewer, request); return validateOutput( viewer.platformDetails, subscriptionUpdateResponseValidator, { threadSubscription, }, ); } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: mixed, ): Promise { const request = await validateInput( viewer, accountUpdateInputValidator, input, ); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { if (!viewer.isSocket) { await verifyClientSupported(viewer, viewer.platformDetails); } await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: mixed, ): Promise { const request = await validateInput( viewer, resetPasswordRequestInputValidator, input, ); await checkAndSendPasswordResetEmail(request); } export const logOutResponseValidator: TInterface = tShape({ currentUserInfo: loggedOutUserInfoValidator, }); async function logOutResponder(viewer: Viewer): Promise { if (!viewer.isSocket) { await verifyClientSupported(viewer, viewer.platformDetails); } 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.platformDetails, logOutResponseValidator, response, ); } const deleteAccountRequestInputValidator = tShape({ password: t.maybe(tPassword), }); async function accountDeletionResponder( viewer: Viewer, input: mixed, ): Promise { const request = await validateInput( viewer, deleteAccountRequestInputValidator, input, ); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return validateOutput( viewer.platformDetails, 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: mixed, ): Promise { const request = await validateInput( viewer, registerRequestInputValidator, input, ); 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.platformDetails, 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) + hasMinCodeVersion(viewer.platformDetails, { native: 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(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: mixed, ): Promise { const request = await validateInput( viewer, logInRequestInputValidator, 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)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 150 })) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { - if (hasMinCodeVersion(viewer.platformDetails, 150)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 150 })) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); const response = await processSuccessfulLogin({ viewer, input, userID: id, calendarQuery, signedIdentityKeysBlob, }); return validateOutput( viewer.platformDetails, logInResponseValidator, response, ); } const siweAuthRequestInputValidator = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(tID), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), }); async function siweAuthResponder( viewer: Viewer, input: mixed, ): Promise { const request = await validateInput( viewer, siweAuthRequestInputValidator, 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.platformDetails, logInResponseValidator, response, ); } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: mixed, ): Promise { const request = await validateInput( viewer, updatePasswordRequestInputValidator, input, ); if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } const response = await updatePassword(viewer, request); return validateOutput( viewer.platformDetails, 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: mixed, ): Promise { const request = await validateInput( viewer, updateUserSettingsInputValidator, input, ); await updateUserSettings(viewer, request); } const policyAcknowledgmentRequestInputValidator = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, input: mixed, ): Promise { const request = await validateInput( viewer, policyAcknowledgmentRequestInputValidator, input, ); 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: mixed, ): Promise { const request = await validateInput( viewer, updateUserAvatarRequestValidator, input, ); const result = await updateUserAvatar(viewer, request); return validateOutput( viewer.platformDetails, updateUserAvatarResponderValidator, result, ); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, }; diff --git a/keyserver/src/session/cookies.js b/keyserver/src/session/cookies.js index 903b42858..a1e124140 100644 --- a/keyserver/src/session/cookies.js +++ b/keyserver/src/session/cookies.js @@ -1,877 +1,877 @@ // @flow import crypto from 'crypto'; import type { $Response, $Request } from 'express'; import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import url from 'url'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import { isWebPlatform } from 'lib/types/device-types.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { type ServerSessionChange, cookieLifetime, cookieSources, type CookieSource, cookieTypes, sessionIdentifierTypes, type SessionIdentifierType, } from 'lib/types/session-types.js'; import type { SIWESocialProof } from 'lib/types/siwe-types.js'; import type { InitialClientSocketMessage } from 'lib/types/socket-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { Viewer } from './viewer.js'; import type { AnonymousViewerData, UserViewerData } from './viewer.js'; import createIDs from '../creators/id-creator.js'; import { createSession } from '../creators/session-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { clearDeviceToken } from '../updaters/device-token-updaters.js'; import { updateThreadMembers } from '../updaters/thread-updaters.js'; import { assertSecureRequest } from '../utils/security-utils.js'; import { type AppURLFacts, getAppURLFactsFromRequestURL, } from '../utils/urls.js'; function cookieIsExpired(lastUsed: number) { return lastUsed + cookieLifetime <= Date.now(); } type SessionParameterInfo = { isSocket: boolean, sessionID: ?string, sessionIdentifierType: SessionIdentifierType, ipAddress: string, userAgent: ?string, }; type FetchViewerResult = | { type: 'valid', viewer: Viewer } | InvalidFetchViewerResult; type InvalidFetchViewerResult = | { type: 'nonexistant', cookieName: ?string, cookieSource: ?CookieSource, sessionParameterInfo: SessionParameterInfo, } | { type: 'invalidated', cookieName: string, cookieID: string, cookieSource: CookieSource, sessionParameterInfo: SessionParameterInfo, platformDetails: ?PlatformDetails, deviceToken: ?string, }; async function fetchUserViewer( cookie: string, cookieSource: CookieSource, sessionParameterInfo: SessionParameterInfo, ): Promise { const [cookieID, cookiePassword] = cookie.split(':'); if (!cookieID || !cookiePassword) { return { type: 'nonexistant', cookieName: cookieTypes.USER, cookieSource, sessionParameterInfo, }; } const query = SQL` SELECT hash, user, last_used, platform, device_token, versions FROM cookies WHERE id = ${cookieID} AND user IS NOT NULL `; const [[result], allSessionInfo] = await Promise.all([ dbQuery(query), fetchSessionInfo(sessionParameterInfo, cookieID), ]); if (result.length === 0) { return { type: 'nonexistant', cookieName: cookieTypes.USER, cookieSource, sessionParameterInfo, }; } let sessionID = null, sessionInfo = null; if (allSessionInfo) { ({ sessionID, ...sessionInfo } = allSessionInfo); } const cookieRow = result[0]; let platformDetails = null; if (cookieRow.versions) { const versions = JSON.parse(cookieRow.versions); platformDetails = { platform: cookieRow.platform, codeVersion: versions.codeVersion, stateVersion: versions.stateVersion, }; } else { platformDetails = { platform: cookieRow.platform }; } const deviceToken = cookieRow.device_token; if ( !bcrypt.compareSync(cookiePassword, cookieRow.hash) || cookieIsExpired(cookieRow.last_used) ) { return { type: 'invalidated', cookieName: cookieTypes.USER, cookieID, cookieSource, sessionParameterInfo, platformDetails, deviceToken, }; } const userID = cookieRow.user.toString(); const viewer = new Viewer({ isSocket: sessionParameterInfo.isSocket, loggedIn: true, id: userID, platformDetails, deviceToken, userID, cookieSource, cookieID, cookiePassword, sessionIdentifierType: sessionParameterInfo.sessionIdentifierType, sessionID, sessionInfo, isScriptViewer: false, ipAddress: sessionParameterInfo.ipAddress, userAgent: sessionParameterInfo.userAgent, }); return { type: 'valid', viewer }; } async function fetchAnonymousViewer( cookie: string, cookieSource: CookieSource, sessionParameterInfo: SessionParameterInfo, ): Promise { const [cookieID, cookiePassword] = cookie.split(':'); if (!cookieID || !cookiePassword) { return { type: 'nonexistant', cookieName: cookieTypes.ANONYMOUS, cookieSource, sessionParameterInfo, }; } const query = SQL` SELECT last_used, hash, platform, device_token, versions FROM cookies WHERE id = ${cookieID} AND user IS NULL `; const [[result], allSessionInfo] = await Promise.all([ dbQuery(query), fetchSessionInfo(sessionParameterInfo, cookieID), ]); if (result.length === 0) { return { type: 'nonexistant', cookieName: cookieTypes.ANONYMOUS, cookieSource, sessionParameterInfo, }; } let sessionID = null, sessionInfo = null; if (allSessionInfo) { ({ sessionID, ...sessionInfo } = allSessionInfo); } const cookieRow = result[0]; let platformDetails = null; if (cookieRow.platform && cookieRow.versions) { const versions = JSON.parse(cookieRow.versions); platformDetails = { platform: cookieRow.platform, codeVersion: versions.codeVersion, stateVersion: versions.stateVersion, }; } else if (cookieRow.platform) { platformDetails = { platform: cookieRow.platform }; } const deviceToken = cookieRow.device_token; if ( !bcrypt.compareSync(cookiePassword, cookieRow.hash) || cookieIsExpired(cookieRow.last_used) ) { return { type: 'invalidated', cookieName: cookieTypes.ANONYMOUS, cookieID, cookieSource, sessionParameterInfo, platformDetails, deviceToken, }; } const viewer = new Viewer({ isSocket: sessionParameterInfo.isSocket, loggedIn: false, id: cookieID, platformDetails, deviceToken, cookieSource, cookieID, cookiePassword, sessionIdentifierType: sessionParameterInfo.sessionIdentifierType, sessionID, sessionInfo, isScriptViewer: false, ipAddress: sessionParameterInfo.ipAddress, userAgent: sessionParameterInfo.userAgent, }); return { type: 'valid', viewer }; } type SessionInfo = { +sessionID: ?string, +lastValidated: number, +lastUpdate: number, +calendarQuery: CalendarQuery, }; async function fetchSessionInfo( sessionParameterInfo: SessionParameterInfo, cookieID: string, ): Promise { const { sessionID } = sessionParameterInfo; const session = sessionID !== undefined ? sessionID : cookieID; if (!session) { return null; } const query = SQL` SELECT query, last_validated, last_update FROM sessions WHERE id = ${session} AND cookie = ${cookieID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return { sessionID, lastValidated: result[0].last_validated, lastUpdate: result[0].last_update, calendarQuery: JSON.parse(result[0].query), }; } // This function is meant to consume a cookie that has already been processed. // That means it doesn't have any logic to handle an invalid cookie, and it // doesn't update the cookie's last_used timestamp. async function fetchViewerFromCookieData( req: $Request, sessionParameterInfo: SessionParameterInfo, ): Promise { let viewerResult; const { user, anonymous } = req.cookies; if (user) { viewerResult = await fetchUserViewer( user, cookieSources.HEADER, sessionParameterInfo, ); } else if (anonymous) { viewerResult = await fetchAnonymousViewer( anonymous, cookieSources.HEADER, sessionParameterInfo, ); } else { return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } // We protect against CSRF attacks by making sure that on web, // non-GET requests cannot use a bare cookie for session identification if (viewerResult.type === 'valid') { const { viewer } = viewerResult; invariant( req.method === 'GET' || viewer.sessionIdentifierType !== sessionIdentifierTypes.COOKIE_ID || !isWebPlatform(viewer.platform), 'non-GET request from web using sessionIdentifierTypes.COOKIE_ID', ); } return viewerResult; } async function fetchViewerFromRequestBody( body: mixed, sessionParameterInfo: SessionParameterInfo, ): Promise { if (!body || typeof body !== 'object') { return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } const cookiePair = body.cookie; if (cookiePair === null || cookiePair === '') { return { type: 'nonexistant', cookieName: null, cookieSource: cookieSources.BODY, sessionParameterInfo, }; } if (!cookiePair || typeof cookiePair !== 'string') { return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } const [type, cookie] = cookiePair.split('='); if (type === cookieTypes.USER && cookie) { return await fetchUserViewer( cookie, cookieSources.BODY, sessionParameterInfo, ); } else if (type === cookieTypes.ANONYMOUS && cookie) { return await fetchAnonymousViewer( cookie, cookieSources.BODY, sessionParameterInfo, ); } return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } function getRequestIPAddress(req: $Request) { const { proxy } = getAppURLFactsFromRequestURL(req.originalUrl); let ipAddress; if (proxy === 'none') { ipAddress = req.socket.remoteAddress; } else if (proxy === 'apache') { ipAddress = req.get('X-Forwarded-For'); } invariant(ipAddress, 'could not determine requesting IP address'); return ipAddress; } function getSessionParameterInfoFromRequestBody( req: $Request, ): SessionParameterInfo { const body = (req.body: any); let sessionID = body.sessionID !== undefined || req.method !== 'GET' ? body.sessionID : null; if (sessionID === '') { sessionID = null; } const sessionIdentifierType = req.method === 'GET' || sessionID !== undefined ? sessionIdentifierTypes.BODY_SESSION_ID : sessionIdentifierTypes.COOKIE_ID; return { isSocket: false, sessionID, sessionIdentifierType, ipAddress: getRequestIPAddress(req), userAgent: req.get('User-Agent'), }; } async function fetchViewerForJSONRequest(req: $Request): Promise { assertSecureRequest(req); const sessionParameterInfo = getSessionParameterInfoFromRequestBody(req); let result = await fetchViewerFromRequestBody(req.body, sessionParameterInfo); if ( result.type === 'nonexistant' && (result.cookieSource === null || result.cookieSource === undefined) ) { result = await fetchViewerFromCookieData(req, sessionParameterInfo); } return await handleFetchViewerResult(result); } const webPlatformDetails = { platform: 'web' }; async function fetchViewerForHomeRequest(req: $Request): Promise { assertSecureRequest(req); const sessionParameterInfo = getSessionParameterInfoFromRequestBody(req); const result = await fetchViewerFromCookieData(req, sessionParameterInfo); return await handleFetchViewerResult(result, webPlatformDetails); } async function fetchViewerForSocket( req: $Request, clientMessage: InitialClientSocketMessage, ): Promise { assertSecureRequest(req); const { sessionIdentification } = clientMessage.payload; const { sessionID } = sessionIdentification; const sessionParameterInfo = { isSocket: true, sessionID, sessionIdentifierType: sessionID !== undefined ? sessionIdentifierTypes.BODY_SESSION_ID : sessionIdentifierTypes.COOKIE_ID, ipAddress: getRequestIPAddress(req), userAgent: req.get('User-Agent'), }; let result = await fetchViewerFromRequestBody( clientMessage.payload.sessionIdentification, sessionParameterInfo, ); if ( result.type === 'nonexistant' && (result.cookieSource === null || result.cookieSource === undefined) ) { result = await fetchViewerFromCookieData(req, sessionParameterInfo); } if (result.type === 'valid') { return result.viewer; } const promises = {}; if (result.cookieSource === cookieSources.BODY) { // We initialize a socket's Viewer after the WebSocket handshake, since to // properly initialize the Viewer we need a bunch of data, but that data // can't be sent until after the handshake. Consequently, by the time we // know that a cookie may be invalid, we are no longer communicating via // HTTP, and have no way to set a new cookie for HEADER (web) clients. const platformDetails = result.type === 'invalidated' ? result.platformDetails : null; const deviceToken = result.type === 'invalidated' ? result.deviceToken : null; promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails, deviceToken, }); } if (result.type === 'invalidated') { promises.deleteCookie = deleteCookie(result.cookieID); } const { anonymousViewerData } = await promiseAll(promises); if (!anonymousViewerData) { return null; } return createViewerForInvalidFetchViewerResult(result, anonymousViewerData); } async function handleFetchViewerResult( result: FetchViewerResult, inputPlatformDetails?: PlatformDetails, ) { if (result.type === 'valid') { return result.viewer; } let platformDetails = inputPlatformDetails; if (!platformDetails && result.type === 'invalidated') { platformDetails = result.platformDetails; } const deviceToken = result.type === 'invalidated' ? result.deviceToken : null; const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails, deviceToken }), result.type === 'invalidated' ? deleteCookie(result.cookieID) : null, ]); return createViewerForInvalidFetchViewerResult(result, anonymousViewerData); } function createViewerForInvalidFetchViewerResult( result: InvalidFetchViewerResult, anonymousViewerData: AnonymousViewerData, ): Viewer { // If a null cookie was specified in the request body, result.cookieSource // will still be BODY here. The only way it would be null or undefined here // is if there was no cookie specified in either the body or the header, in // which case we default to returning the new cookie in the response header. const cookieSource = result.cookieSource !== null && result.cookieSource !== undefined ? result.cookieSource : cookieSources.HEADER; const viewer = new Viewer({ ...anonymousViewerData, cookieSource, sessionIdentifierType: result.sessionParameterInfo.sessionIdentifierType, isSocket: result.sessionParameterInfo.isSocket, ipAddress: result.sessionParameterInfo.ipAddress, userAgent: result.sessionParameterInfo.userAgent, }); viewer.sessionChanged = true; // If cookieName is falsey, that tells us that there was no cookie specified // in the request, which means we can't be invalidating anything. if (result.cookieName) { viewer.cookieInvalidated = true; viewer.initialCookieName = result.cookieName; } return viewer; } function addSessionChangeInfoToResult( viewer: Viewer, res: $Response, result: Object, appURLFacts: AppURLFacts, ) { let threadInfos = {}, userInfos = {}; if (result.cookieChange) { ({ threadInfos, userInfos } = result.cookieChange); } let sessionChange; if (viewer.cookieInvalidated) { sessionChange = ({ cookieInvalidated: true, threadInfos, userInfos: (values(userInfos).map(a => a): UserInfo[]), currentUserInfo: { id: viewer.cookieID, anonymous: true, }, }: ServerSessionChange); } else { sessionChange = ({ cookieInvalidated: false, threadInfos, userInfos: (values(userInfos).map(a => a): UserInfo[]), }: ServerSessionChange); } if (viewer.cookieSource === cookieSources.BODY) { sessionChange.cookie = viewer.cookiePairString; } else { addActualHTTPCookie(viewer, res, appURLFacts); } if (viewer.sessionIdentifierType === sessionIdentifierTypes.BODY_SESSION_ID) { sessionChange.sessionID = viewer.sessionID ? viewer.sessionID : null; } result.cookieChange = sessionChange; } type AnonymousCookieCreationParams = Shape<{ +platformDetails: ?PlatformDetails, +deviceToken: ?string, }>; const defaultPlatformDetails = {}; // The result of this function should not be passed directly to the Viewer // constructor. Instead, it should be passed to viewer.setNewCookie. There are // several fields on AnonymousViewerData that are not set by this function: // sessionIdentifierType, cookieSource, ipAddress, and userAgent. These // parameters all depend on the initial request. If the result of this function // is passed to the Viewer constructor directly, the resultant Viewer object // will throw whenever anybody attempts to access the relevant properties. async function createNewAnonymousCookie( params: AnonymousCookieCreationParams, ): Promise { const { platformDetails, deviceToken } = params; const { platform, ...versions } = platformDetails || defaultPlatformDetails; const versionsString = Object.keys(versions).length > 0 ? JSON.stringify(versions) : null; const time = Date.now(); const cookiePassword = crypto.randomBytes(32).toString('hex'); const cookieHash = bcrypt.hashSync(cookiePassword); const [[id]] = await Promise.all([ createIDs('cookies', 1), deviceToken ? clearDeviceToken(deviceToken) : undefined, ]); const cookieRow = [ id, cookieHash, null, platform, time, time, deviceToken, versionsString, ]; const query = SQL` INSERT INTO cookies(id, hash, user, platform, creation_time, last_used, device_token, versions) VALUES ${[cookieRow]} `; await dbQuery(query); return { loggedIn: false, id, platformDetails, deviceToken, cookieID: id, cookiePassword, sessionID: undefined, sessionInfo: null, cookieInsertedThisRequest: true, isScriptViewer: false, }; } type UserCookieCreationParams = { +platformDetails: PlatformDetails, +deviceToken?: ?string, +socialProof?: ?SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, }; // The result of this function should never be passed directly to the Viewer // constructor. Instead, it should be passed to viewer.setNewCookie. There are // several fields on UserViewerData that are not set by this function: // sessionID, sessionIdentifierType, cookieSource, and ipAddress. These // parameters all depend on the initial request. If the result of this function // is passed to the Viewer constructor directly, the resultant Viewer object // will throw whenever anybody attempts to access the relevant properties. async function createNewUserCookie( userID: string, params: UserCookieCreationParams, ): Promise { const { platformDetails, deviceToken, socialProof, signedIdentityKeysBlob } = params; const { platform, ...versions } = platformDetails || defaultPlatformDetails; const versionsString = Object.keys(versions).length > 0 ? JSON.stringify(versions) : null; const time = Date.now(); const cookiePassword = crypto.randomBytes(32).toString('hex'); const cookieHash = bcrypt.hashSync(cookiePassword); const [[cookieID]] = await Promise.all([ createIDs('cookies', 1), deviceToken ? clearDeviceToken(deviceToken) : undefined, ]); const cookieRow = [ cookieID, cookieHash, userID, platform, time, time, deviceToken, versionsString, JSON.stringify(socialProof), signedIdentityKeysBlob ? JSON.stringify(signedIdentityKeysBlob) : null, ]; const query = SQL` INSERT INTO cookies(id, hash, user, platform, creation_time, last_used, device_token, versions, social_proof, signed_identity_keys) VALUES ${[cookieRow]} `; await dbQuery(query); return { loggedIn: true, id: userID, platformDetails, deviceToken, userID, cookieID, sessionID: undefined, sessionInfo: null, cookiePassword, cookieInsertedThisRequest: true, isScriptViewer: false, }; } // This gets called after createNewUserCookie and from websiteResponder. If the // Viewer's sessionIdentifierType is COOKIE_ID then the cookieID is used as the // session identifier; otherwise, a new ID is created for the session. async function setNewSession( viewer: Viewer, calendarQuery: CalendarQuery, initialLastUpdate: number, ): Promise { if (viewer.sessionIdentifierType !== sessionIdentifierTypes.COOKIE_ID) { const [sessionID] = await createIDs('sessions', 1); viewer.setSessionID(sessionID); } await createSession(viewer, calendarQuery, initialLastUpdate); } async function extendCookieLifespan(cookieID: string) { const time = Date.now(); const query = SQL` UPDATE cookies SET last_used = ${time} WHERE id = ${cookieID} `; await dbQuery(query); } function addCookieToJSONResponse( viewer: Viewer, res: $Response, result: Object, expectCookieInvalidation: boolean, appURLFacts: AppURLFacts, ) { if (expectCookieInvalidation) { viewer.cookieInvalidated = false; } if (!viewer.getData().cookieInsertedThisRequest) { handleAsyncPromise(extendCookieLifespan(viewer.cookieID)); } if (viewer.sessionChanged) { addSessionChangeInfoToResult(viewer, res, result, appURLFacts); } else if (viewer.cookieSource !== cookieSources.BODY) { addActualHTTPCookie(viewer, res, appURLFacts); } } function addCookieToHomeResponse( viewer: Viewer, res: $Response, appURLFacts: AppURLFacts, ) { if (!viewer.getData().cookieInsertedThisRequest) { handleAsyncPromise(extendCookieLifespan(viewer.cookieID)); } addActualHTTPCookie(viewer, res, appURLFacts); } function getCookieOptions(appURLFacts: AppURLFacts) { const { baseDomain, basePath, https } = appURLFacts; const domainAsURL = new url.URL(baseDomain); return { domain: domainAsURL.hostname, path: basePath, httpOnly: true, secure: https, maxAge: cookieLifetime, sameSite: 'Strict', }; } function addActualHTTPCookie( viewer: Viewer, res: $Response, appURLFacts: AppURLFacts, ) { res.cookie( viewer.cookieName, viewer.cookieString, getCookieOptions(appURLFacts), ); if (viewer.cookieName !== viewer.initialCookieName) { res.clearCookie(viewer.initialCookieName, getCookieOptions(appURLFacts)); } } async function setCookieSignedIdentityKeysBlob( cookieID: string, signedIdentityKeysBlob: SignedIdentityKeysBlob, ) { const signedIdentityKeysStr = JSON.stringify(signedIdentityKeysBlob); const query = SQL` UPDATE cookies SET signed_identity_keys = ${signedIdentityKeysStr} WHERE id = ${cookieID} `; await dbQuery(query); } // Returns `true` if row with `id = cookieID` exists AND // `signed_identity_keys` is `NULL`. Otherwise, returns `false`. async function isCookieMissingSignedIdentityKeysBlob( cookieID: string, ): Promise { const query = SQL` SELECT signed_identity_keys FROM cookies WHERE id = ${cookieID} `; const [queryResult] = await dbQuery(query); return ( queryResult.length === 1 && queryResult[0].signed_identity_keys === null ); } async function setCookiePlatform( viewer: Viewer, platform: Platform, ): Promise { const newPlatformDetails = { ...viewer.platformDetails, platform }; viewer.setPlatformDetails(newPlatformDetails); const query = SQL` UPDATE cookies SET platform = ${platform} WHERE id = ${viewer.cookieID} `; await dbQuery(query); } async function setCookiePlatformDetails( viewer: Viewer, platformDetails: PlatformDetails, ): Promise { if ( - hasMinCodeVersion(platformDetails, 70) && - !hasMinCodeVersion(viewer.platformDetails, 70) + hasMinCodeVersion(platformDetails, { native: 70 }) && + !hasMinCodeVersion(viewer.platformDetails, { native: 70 }) ) { await updateThreadMembers(viewer); } viewer.setPlatformDetails(platformDetails); const { platform, ...versions } = platformDetails; const versionsString = Object.keys(versions).length > 0 ? JSON.stringify(versions) : null; const query = SQL` UPDATE cookies SET platform = ${platform}, versions = ${versionsString} WHERE id = ${viewer.cookieID} `; await dbQuery(query); } export { fetchViewerForJSONRequest, fetchViewerForHomeRequest, fetchViewerForSocket, createNewAnonymousCookie, createNewUserCookie, setNewSession, extendCookieLifespan, addCookieToJSONResponse, addCookieToHomeResponse, setCookieSignedIdentityKeysBlob, isCookieMissingSignedIdentityKeysBlob, setCookiePlatform, setCookiePlatformDetails, }; diff --git a/keyserver/src/session/version.js b/keyserver/src/session/version.js index 9477feab2..a4efaf0df 100644 --- a/keyserver/src/session/version.js +++ b/keyserver/src/session/version.js @@ -1,21 +1,21 @@ // @flow import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { PlatformDetails } from 'lib/types/device-types.js'; import { ServerError } from 'lib/utils/errors.js'; import type { Viewer } from './viewer.js'; async function verifyClientSupported( viewer: Viewer, platformDetails: ?PlatformDetails, ) { - if (hasMinCodeVersion(platformDetails, 31)) { + if (hasMinCodeVersion(platformDetails, { native: 31 })) { return; } const error = new ServerError('client_version_unsupported'); error.platformDetails = platformDetails; throw error; } export { verifyClientSupported }; diff --git a/keyserver/src/socket/session-utils.js b/keyserver/src/socket/session-utils.js index e0666abae..77d19704d 100644 --- a/keyserver/src/socket/session-utils.js +++ b/keyserver/src/socket/session-utils.js @@ -1,601 +1,603 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import type { TUnion } from 'tcomb'; import { usersInRawEntryInfos, serverEntryInfo, serverEntryInfosObject, } from 'lib/shared/entry-utils.js'; import { usersInThreadInfo } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { UpdateActivityResult } from 'lib/types/activity-types.js'; import type { IdentityKeysBlob } from 'lib/types/crypto-types.js'; import { isDeviceType } from 'lib/types/device-types.js'; import type { CalendarQuery, DeltaEntryInfosResponse, } from 'lib/types/entry-types.js'; import { reportTypes, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, } from 'lib/types/report-types.js'; import { serverRequestTypes, type ThreadInconsistencyClientResponse, type EntryInconsistencyClientResponse, type ClientResponse, type ServerServerRequest, type ServerCheckStateServerRequest, } from 'lib/types/request-types.js'; import { sessionCheckFrequency } from 'lib/types/session-types.js'; import { signedIdentityKeysBlobValidator } from 'lib/utils/crypto-utils.js'; import { hash } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { tShape, tPlatform, tPlatformDetails, } from 'lib/utils/validation-utils.js'; import { saveOneTimeKeys } from '../creators/one-time-keys-creator.js'; import createReport from '../creators/report-creator.js'; import { SQL } from '../database/database.js'; import { fetchEntryInfos, fetchEntryInfosByID, fetchEntriesForSession, } from '../fetchers/entry-fetchers.js'; import { checkIfSessionHasEnoughOneTimeKeys } from '../fetchers/key-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchCurrentUserInfo, fetchUserInfos, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { activityUpdatesInputValidator } from '../responders/activity-responders.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, } from '../responders/report-responders.js'; import { setNewSession, setCookiePlatform, setCookiePlatformDetails, setCookieSignedIdentityKeysBlob, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { activityUpdater } from '../updaters/activity-updaters.js'; import { compareNewCalendarQuery } from '../updaters/entry-updaters.js'; import type { SessionUpdate } from '../updaters/session-updaters.js'; import { getOlmUtility } from '../utils/olm-utils.js'; const clientResponseInputValidator: TUnion = t.union([ tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM', x => x === serverRequestTypes.PLATFORM, ), platform: tPlatform, }), tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.THREAD_INCONSISTENCY', x => x === serverRequestTypes.THREAD_INCONSISTENCY, ), }), tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.ENTRY_INCONSISTENCY', x => x === serverRequestTypes.ENTRY_INCONSISTENCY, ), }), tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM_DETAILS', x => x === serverRequestTypes.PLATFORM_DETAILS, ), platformDetails: tPlatformDetails, }), tShape({ type: t.irreducible( 'serverRequestTypes.CHECK_STATE', x => x === serverRequestTypes.CHECK_STATE, ), hashResults: t.dict(t.String, t.Boolean), }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_ACTIVITY_UPDATES', x => x === serverRequestTypes.INITIAL_ACTIVITY_UPDATES, ), activityUpdates: activityUpdatesInputValidator, }), tShape({ type: t.irreducible( 'serverRequestTypes.MORE_ONE_TIME_KEYS', x => x === serverRequestTypes.MORE_ONE_TIME_KEYS, ), keys: t.list(t.String), }), tShape({ type: t.irreducible( 'serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB', x => x === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, ), signedIdentityKeysBlob: signedIdentityKeysBlobValidator, }), ]); type StateCheckStatus = | { status: 'state_validated' } | { status: 'state_invalid', invalidKeys: $ReadOnlyArray } | { status: 'state_check' }; type ProcessClientResponsesResult = { serverRequests: ServerServerRequest[], stateCheckStatus: ?StateCheckStatus, activityUpdateResult: ?UpdateActivityResult, }; async function processClientResponses( viewer: Viewer, clientResponses: $ReadOnlyArray, ): Promise { let viewerMissingPlatform = !viewer.platform; const { platformDetails } = viewer; let viewerMissingPlatformDetails = !platformDetails || (isDeviceType(viewer.platform) && (platformDetails.codeVersion === null || platformDetails.codeVersion === undefined || platformDetails.stateVersion === null || platformDetails.stateVersion === undefined)); const promises = []; let activityUpdates = []; let stateCheckStatus = null; const clientSentPlatformDetails = clientResponses.some( response => response.type === serverRequestTypes.PLATFORM_DETAILS, ); for (const clientResponse of clientResponses) { if ( clientResponse.type === serverRequestTypes.PLATFORM && !clientSentPlatformDetails ) { promises.push(setCookiePlatform(viewer, clientResponse.platform)); viewerMissingPlatform = false; if (!isDeviceType(clientResponse.platform)) { viewerMissingPlatformDetails = false; } } else if ( clientResponse.type === serverRequestTypes.THREAD_INCONSISTENCY ) { promises.push(recordThreadInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.ENTRY_INCONSISTENCY) { promises.push(recordEntryInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.PLATFORM_DETAILS) { promises.push( setCookiePlatformDetails(viewer, clientResponse.platformDetails), ); viewerMissingPlatform = false; viewerMissingPlatformDetails = false; } else if ( clientResponse.type === serverRequestTypes.INITIAL_ACTIVITY_UPDATES ) { activityUpdates = [...activityUpdates, ...clientResponse.activityUpdates]; } else if (clientResponse.type === serverRequestTypes.CHECK_STATE) { const invalidKeys = []; for (const key in clientResponse.hashResults) { const result = clientResponse.hashResults[key]; if (!result) { invalidKeys.push(key); } } stateCheckStatus = invalidKeys.length > 0 ? { status: 'state_invalid', invalidKeys } : { status: 'state_validated' }; } else if (clientResponse.type === serverRequestTypes.MORE_ONE_TIME_KEYS) { invariant(clientResponse.keys, 'keys expected in client response'); handleAsyncPromise(saveOneTimeKeys(viewer, clientResponse.keys)); } else if ( clientResponse.type === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB ) { invariant( clientResponse.signedIdentityKeysBlob, 'signedIdentityKeysBlob expected in client response', ); const { signedIdentityKeysBlob } = clientResponse; const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); const olmUtil = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); handleAsyncPromise( setCookieSignedIdentityKeysBlob( viewer.cookieID, signedIdentityKeysBlob, ), ); } catch (e) { continue; } } } const activityUpdatePromise = (async () => { if (activityUpdates.length === 0) { return undefined; } return await activityUpdater(viewer, { updates: activityUpdates }); })(); const serverRequests = []; const checkOneTimeKeysPromise = (async () => { if (!viewer.loggedIn) { return; } const enoughOneTimeKeys = await checkIfSessionHasEnoughOneTimeKeys( viewer.session, ); if (!enoughOneTimeKeys) { serverRequests.push({ type: serverRequestTypes.MORE_ONE_TIME_KEYS }); } })(); const { activityUpdateResult } = await promiseAll({ all: Promise.all(promises), activityUpdateResult: activityUpdatePromise, checkOneTimeKeysPromise, }); if ( !stateCheckStatus && viewer.loggedIn && viewer.sessionLastValidated + sessionCheckFrequency < Date.now() ) { stateCheckStatus = { status: 'state_check' }; } if (viewerMissingPlatform) { serverRequests.push({ type: serverRequestTypes.PLATFORM }); } if (viewerMissingPlatformDetails) { serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS }); } return { serverRequests, stateCheckStatus, activityUpdateResult }; } async function recordThreadInconsistency( viewer: Viewer, response: ThreadInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.THREAD_INCONSISTENCY, }: ThreadInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } async function recordEntryInconsistency( viewer: Viewer, response: EntryInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.ENTRY_INCONSISTENCY, }: EntryInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } type SessionInitializationResult = | { sessionContinued: false } | { sessionContinued: true, deltaEntryInfoResult: DeltaEntryInfosResponse, sessionUpdate: SessionUpdate, }; async function initializeSession( viewer: Viewer, calendarQuery: CalendarQuery, oldLastUpdate: number, ): Promise { if (!viewer.loggedIn) { return { sessionContinued: false }; } if (!viewer.hasSessionInfo) { // If the viewer has no session info but is logged in, that is indicative // of an expired / invalidated session and we should generate a new one await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } if (oldLastUpdate < viewer.sessionLastUpdated) { // If the client has an older last_update than the server is tracking for // that client, then the client either had some issue persisting its store, // or the user restored the client app from a backup. Either way, we should // invalidate the existing session, since the server has assumed that the // checkpoint is further along than it is on the client, and might not still // have all of the updates necessary to do an incremental update await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } let comparisonResult = null; try { comparisonResult = compareNewCalendarQuery(viewer, calendarQuery); } catch (e) { if (e.message !== 'unknown_error') { throw e; } } if (comparisonResult) { const { difference, oldCalendarQuery } = comparisonResult; const sessionUpdate = { ...comparisonResult.sessionUpdate, lastUpdate: oldLastUpdate, }; const deltaEntryInfoResult = await fetchEntriesForSession( viewer, difference, oldCalendarQuery, ); return { sessionContinued: true, deltaEntryInfoResult, sessionUpdate }; } else { await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } } type StateCheckResult = { sessionUpdate?: SessionUpdate, checkStateRequest?: ServerCheckStateServerRequest, }; async function checkState( viewer: Viewer, status: StateCheckStatus, calendarQuery: CalendarQuery, ): Promise { - const shouldCheckUserInfos = hasMinCodeVersion(viewer.platformDetails, 59); + const shouldCheckUserInfos = hasMinCodeVersion(viewer.platformDetails, { + native: 59, + }); if (status.status === 'state_validated') { return { sessionUpdate: { lastValidated: Date.now() } }; } else if (status.status === 'state_check') { const promises = { threadsResult: fetchThreadInfos(viewer), entriesResult: fetchEntryInfos(viewer, [calendarQuery]), currentUserInfo: fetchCurrentUserInfo(viewer), userInfosResult: undefined, }; if (shouldCheckUserInfos) { promises.userInfosResult = fetchKnownUserInfos(viewer); } const fetchedData = await promiseAll(promises); let hashesToCheck = { threadInfos: hash(fetchedData.threadsResult.threadInfos), entryInfos: hash( serverEntryInfosObject(fetchedData.entriesResult.rawEntryInfos), ), currentUserInfo: hash(fetchedData.currentUserInfo), }; if (shouldCheckUserInfos) { hashesToCheck = { ...hashesToCheck, userInfos: hash(fetchedData.userInfosResult), }; } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, }; return { checkStateRequest }; } const { invalidKeys } = status; let fetchAllThreads = false, fetchAllEntries = false, fetchAllUserInfos = false, fetchUserInfo = false; const threadIDsToFetch = [], entryIDsToFetch = [], userIDsToFetch = []; for (const key of invalidKeys) { if (key === 'threadInfos') { fetchAllThreads = true; } else if (key === 'entryInfos') { fetchAllEntries = true; } else if (key === 'userInfos') { fetchAllUserInfos = true; } else if (key === 'currentUserInfo') { fetchUserInfo = true; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); threadIDsToFetch.push(threadID); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); entryIDsToFetch.push(entryID); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); userIDsToFetch.push(userID); } } const fetchPromises = {}; if (fetchAllThreads) { fetchPromises.threadsResult = fetchThreadInfos(viewer); } else if (threadIDsToFetch.length > 0) { fetchPromises.threadsResult = fetchThreadInfos( viewer, SQL`t.id IN (${threadIDsToFetch})`, ); } if (fetchAllEntries) { fetchPromises.entriesResult = fetchEntryInfos(viewer, [calendarQuery]); } else if (entryIDsToFetch.length > 0) { fetchPromises.entryInfos = fetchEntryInfosByID(viewer, entryIDsToFetch); } if (fetchAllUserInfos) { fetchPromises.userInfos = fetchKnownUserInfos(viewer); } else if (userIDsToFetch.length > 0) { fetchPromises.userInfos = fetchKnownUserInfos(viewer, userIDsToFetch); } if (fetchUserInfo) { fetchPromises.currentUserInfo = fetchCurrentUserInfo(viewer); } const fetchedData = await promiseAll(fetchPromises); const hashesToCheck = {}, failUnmentioned = {}, stateChanges = {}; for (const key of invalidKeys) { if (key === 'threadInfos') { // Instead of returning all threadInfos, we want to narrow down and figure // out which threadInfos don't match first const { threadInfos } = fetchedData.threadsResult; for (const threadID in threadInfos) { hashesToCheck[`threadInfo|${threadID}`] = hash(threadInfos[threadID]); } failUnmentioned.threadInfos = true; } else if (key === 'entryInfos') { // Instead of returning all entryInfos, we want to narrow down and figure // out which entryInfos don't match first const { rawEntryInfos } = fetchedData.entriesResult; for (const rawEntryInfo of rawEntryInfos) { const entryInfo = serverEntryInfo(rawEntryInfo); invariant(entryInfo, 'should be set'); const { id: entryID } = entryInfo; invariant(entryID, 'should be set'); hashesToCheck[`entryInfo|${entryID}`] = hash(entryInfo); } failUnmentioned.entryInfos = true; } else if (key === 'userInfos') { // Instead of returning all userInfos, we want to narrow down and figure // out which userInfos don't match first const { userInfos } = fetchedData; for (const userID in userInfos) { hashesToCheck[`userInfo|${userID}`] = hash(userInfos[userID]); } failUnmentioned.userInfos = true; } else if (key === 'currentUserInfo') { stateChanges.currentUserInfo = fetchedData.currentUserInfo; } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); const { threadInfos } = fetchedData.threadsResult; const threadInfo = threadInfos[threadID]; if (!threadInfo) { if (!stateChanges.deleteThreadIDs) { stateChanges.deleteThreadIDs = []; } stateChanges.deleteThreadIDs.push(threadID); continue; } if (!stateChanges.rawThreadInfos) { stateChanges.rawThreadInfos = []; } stateChanges.rawThreadInfos.push(threadInfo); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); const rawEntryInfos = fetchedData.entriesResult ? fetchedData.entriesResult.rawEntryInfos : fetchedData.entryInfos; const entryInfo = rawEntryInfos.find( candidate => candidate.id === entryID, ); if (!entryInfo) { if (!stateChanges.deleteEntryIDs) { stateChanges.deleteEntryIDs = []; } stateChanges.deleteEntryIDs.push(entryID); continue; } if (!stateChanges.rawEntryInfos) { stateChanges.rawEntryInfos = []; } stateChanges.rawEntryInfos.push(entryInfo); } else if (key.startsWith('userInfo|')) { const { userInfos: fetchedUserInfos } = fetchedData; const [, userID] = key.split('|'); const userInfo = fetchedUserInfos[userID]; if (!userInfo || !userInfo.username) { if (!stateChanges.deleteUserInfoIDs) { stateChanges.deleteUserInfoIDs = []; } stateChanges.deleteUserInfoIDs.push(userID); } else { if (!stateChanges.userInfos) { stateChanges.userInfos = []; } stateChanges.userInfos.push({ ...userInfo, // Flow gets confused if we don't do this username: userInfo.username, }); } } } if (!shouldCheckUserInfos) { const userIDs = new Set(); if (stateChanges.rawThreadInfos) { for (const threadInfo of stateChanges.rawThreadInfos) { for (const userID of usersInThreadInfo(threadInfo)) { userIDs.add(userID); } } } if (stateChanges.rawEntryInfos) { for (const userID of usersInRawEntryInfos(stateChanges.rawEntryInfos)) { userIDs.add(userID); } } const userInfos = []; if (userIDs.size > 0) { const fetchedUserInfos = await fetchUserInfos([...userIDs]); for (const userID in fetchedUserInfos) { const userInfo = fetchedUserInfos[userID]; if (userInfo && userInfo.username) { const { id, username } = userInfo; userInfos.push({ id, username }); } } } if (userInfos.length > 0) { stateChanges.userInfos = userInfos; } } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, failUnmentioned, stateChanges, }; if (Object.keys(hashesToCheck).length === 0) { return { checkStateRequest, sessionUpdate: { lastValidated: Date.now() } }; } else { return { checkStateRequest }; } } export { clientResponseInputValidator, processClientResponses, initializeSession, checkState, }; diff --git a/keyserver/src/updaters/account-updaters.js b/keyserver/src/updaters/account-updaters.js index 223f60f08..c0396958f 100644 --- a/keyserver/src/updaters/account-updaters.js +++ b/keyserver/src/updaters/account-updaters.js @@ -1,254 +1,254 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, LogInResponse, } from 'lib/types/account-types.js'; import type { ClientAvatar, UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from 'lib/types/avatar-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import type { CreateUpdatesResult, UpdateData, } from 'lib/types/update-types.js'; import type { PasswordUpdate, UserInfo, UserInfos, } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { getUploadURL } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function accountUpdater( viewer: Viewer, update: PasswordUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newPassword = update.updatedFields.password; if (!newPassword) { // If it's an old client it may have given us an email, // but we don't store those anymore return; } const verifyQuery = SQL` SELECT username, hash FROM users WHERE id = ${viewer.userID} `; const [verifyResult] = await dbQuery(verifyQuery); if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } const changedFields = { hash: bcrypt.hashSync(newPassword) }; const saveQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `; await dbQuery(saveQuery); const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); } // eslint-disable-next-line no-unused-vars async function checkAndSendVerificationEmail(viewer: Viewer): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } async function checkAndSendPasswordResetEmail( // eslint-disable-next-line no-unused-vars request: ResetPasswordRequest, ): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } /* eslint-disable no-unused-vars */ async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { /* eslint-enable no-unused-vars */ // We have no way to handle this request anymore throw new ServerError('deprecated'); } async function updateUserSettings( viewer: Viewer, request: UpdateUserSettingsRequest, ) { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const createOrUpdateSettingsQuery = SQL` INSERT INTO settings (user, name, data) VALUES ${[[viewer.id, request.name, request.data]]} ON DUPLICATE KEY UPDATE data = VALUE(data) `; await dbQuery(createOrUpdateSettingsQuery); } async function updateUserAvatar( viewer: Viewer, request: UpdateUserAvatarRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newAvatarValue = request.type === 'remove' ? null : JSON.stringify(request); const mediaID = request.type === 'image' ? request.uploadID : null; const query = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE uploader = ${viewer.userID} AND container = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${viewer.userID} WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE users SET avatar = ${newAvatarValue} WHERE id = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container = ${viewer.userID} AND thread IS NULL ) ); COMMIT; SELECT id AS upload_id, secret AS upload_secret FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container = ${viewer.userID}; `; const [resultSet] = await dbQuery(query, { multipleStatements: true }); const selectResult = resultSet.pop(); const knownUserInfos: UserInfos = await fetchKnownUserInfos(viewer); const updates: CreateUpdatesResult = await createUserAvatarUpdates( viewer, knownUserInfos, ); - if (hasMinCodeVersion(viewer.platformDetails, 215)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 215 })) { const updateUserAvatarResponse: UpdateUserAvatarResponse = { updates, }; return updateUserAvatarResponse; } if (request.type === 'remove') { return null; } else if (request.type !== 'image') { return request; } else { const [{ upload_id, upload_secret }] = selectResult; const uploadID = upload_id.toString(); invariant( uploadID === request.uploadID, 'uploadID of upload should match uploadID of UpdateUserAvatarRequest', ); return { type: 'image', uri: getUploadURL(uploadID, upload_secret), }; } } async function createUserAvatarUpdates( viewer: Viewer, knownUserInfos: UserInfos, ): Promise { const time = Date.now(); const userUpdates: $ReadOnlyArray = values(knownUserInfos).map( (user: UserInfo): UpdateData => ({ type: updateTypes.UPDATE_USER, userID: user.id, time, updatedUserID: viewer.userID, }), ); const currentUserUpdate: UpdateData = { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time, }; return await createUpdates([...userUpdates, currentUserUpdate], { viewer, updatesForCurrentSession: 'return', }); } export { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updateUserSettings, updatePassword, updateUserAvatar, }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index 4a138a1ff..0afc79cd4 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,1029 +1,1033 @@ // @flow import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, validChatNameRegex, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { reportLinkUsage } from './link-updaters.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { checkIfInviteLinkIsValid } from '../fetchers/link-fetchers.js'; import { fetchMessageInfos, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { updatesResult: { newUpdates: [] }, }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: [], }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); validationPromises.hasNecessaryPermissions = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const { serverThreadInfos } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: nextThreadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = avatar && avatar.type === 'image' ? avatar.uploadID : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; intermediatePromises.updateQuery = updateQueryPromise; intermediatePromises.updateRoles = updateRolesPromise; if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); } const { addMembersChangeset, recalculatePermissionsChangeset } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); let newMessageInfos = []; if (!silenceMessages) { const time = Date.now(); const messageDatas = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } - if (hasMinCodeVersion(viewer.platformDetails, 62)) { + if (hasMinCodeVersion(viewer.platformDetails, { native: 62 })) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const permissionCheck = request.inviteLinkSecret ? checkIfInviteLinkIsValid(request.inviteLinkSecret, request.threadID) : checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ); const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), permissionCheck, ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } // TODO: determine code version - const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); - const hasCodeVersionBelow62 = !hasMinCodeVersion(viewer.platformDetails, 62); + const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, { + native: 87, + }); + const hasCodeVersionBelow62 = !hasMinCodeVersion(viewer.platformDetails, { + native: 62, + }); const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; if (calendarQuery && hasCodeVersionBelow87) { response.rawEntryInfos = []; } if (hasCodeVersionBelow62) { response.threadInfos = {}; } return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); if (request.inviteLinkSecret) { handleAsyncPromise(reportLinkUsage(request.inviteLinkSecret)); } const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); const messageSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; if (!hasCodeVersionBelow87) { return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (hasCodeVersionBelow62) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.MANAGE_PINS, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const togglePinQuery = SQL` UPDATE messages SET pinned = ${pinnedValue}, pin_time = ${pinTimeValue} WHERE id = ${messageID} AND thread = ${threadID} `; const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; let updateThreadQuery; if (action === 'pin') { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count + 1 WHERE id = ${threadID} `; } else { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count - 1 WHERE id = ${threadID} `; } const [{ threadInfos: serverThreadInfos }] = await Promise.all([ fetchServerThreadInfos(SQL`t.id = ${threadID}`), dbQuery(togglePinQuery), dbQuery(updateThreadQuery), ]); const newMessageInfos = await createMessages(viewer, [messageData]); const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, toggleMessagePinForThread, }; diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index af10142e8..0578109a5 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,232 +1,234 @@ // @flow import type { TType } from 'tcomb'; import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion, FUTURE_CODE_VERSION, } from 'lib/shared/version-utils.js'; import { type PlatformDetails, isWebPlatform } from 'lib/types/device-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tCookie, tPassword, tPlatform, tPlatformDetails, assertWithValidator, convertToNewIDSchema, keyserverPrefixID, convertClientIDsToServerIDs, convertObject, convertServerIDsToClientIDs, } 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: mixed, ): Promise { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } const convertedInput = checkInputValidator(inputValidator, input); if ( - hasMinCodeVersion(viewer.platformDetails, FUTURE_CODE_VERSION) && + hasMinCodeVersion(viewer.platformDetails, { + native: FUTURE_CODE_VERSION, + }) && !isWebPlatform(viewer.platformDetails?.platform) && convertToNewIDSchema ) { try { return convertClientIDsToServerIDs( keyserverPrefixID, inputValidator, convertedInput, ); } catch (err) { throw new ServerError(err.message); } } return convertedInput; } function validateOutput( platformDetails: ?PlatformDetails, outputValidator: TType, data: T, ): T { if (!outputValidator.is(data)) { console.trace( 'Output validation failed, validator is:', outputValidator.displayName, ); return data; } if ( - hasMinCodeVersion(platformDetails, FUTURE_CODE_VERSION) && + hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION }) && !isWebPlatform(platformDetails?.platform) && convertToNewIDSchema ) { return convertServerIDsToClientIDs( keyserverPrefixID, outputValidator, data, ); } return data; } function checkInputValidator(inputValidator: TType, input: mixed): T { if (inputValidator.is(input)) { return assertWithValidator(input, inputValidator); } 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; } async function policiesValidator( viewer: Viewer, policies: $ReadOnlyArray, ) { if (!policies.length) { return; } - if (!hasMinCodeVersion(viewer.platformDetails, 181)) { + if (!hasMinCodeVersion(viewer.platformDetails, { native: 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, policiesValidator, }; diff --git a/lib/shared/messages/create-sidebar-message-spec.js b/lib/shared/messages/create-sidebar-message-spec.js index 570059b84..43f2d306f 100644 --- a/lib/shared/messages/create-sidebar-message-spec.js +++ b/lib/shared/messages/create-sidebar-message-spec.js @@ -1,233 +1,233 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type NotificationTextsParams, type RobotextParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from '../../types/messages/create-sidebar.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText, pluralizeEntityText, } from '../../utils/entity-text.js'; import { notifTextsForSidebarCreation } from '../notif-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const createSidebarMessageSpec: MessageSpec< CreateSidebarMessageData, RawCreateSidebarMessageInfo, CreateSidebarMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateSidebarMessageData | RawCreateSidebarMessageInfo, ): string { return JSON.stringify({ ...data.initialThreadState, sourceMessageAuthorID: data.sourceMessageAuthorID, }); }, messageContentForClientDB(data: RawCreateSidebarMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawCreateSidebarMessageInfo { const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( row.content, ); return { type: messageTypes.CREATE_SIDEBAR, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessageAuthorID, initialThreadState, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSidebarMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for CreateSidebar', ); const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( clientDBMessageInfo.content, ); const rawCreateSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: messageTypes.CREATE_SIDEBAR, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessageAuthorID: sourceMessageAuthorID, initialThreadState: initialThreadState, }; return rawCreateSidebarMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSidebarMessageInfo { const { threadInfos } = params; const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; const sourceMessageAuthor = params.createRelativeUserInfos([ rawMessageInfo.sourceMessageAuthorID, ])[0]; if (!sourceMessageAuthor) { return null; } return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessageAuthor, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: params.createRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), ), }, }; }, rawMessageInfoFromMessageData( messageData: CreateSidebarMessageData, id: ?string, ): RawCreateSidebarMessageInfo { invariant(id, 'RawCreateSidebarMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: CreateSidebarMessageInfo, params: RobotextParams, ): EntityText { let text = ET`started ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; const users = messageInfo.initialThreadState.otherMembers.filter( member => member.id !== messageInfo.sourceMessageAuthor.id, ); if (users.length !== 0) { const initialUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); text = ET`${text} and added ${initialUsers}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, shimUnsupportedMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, platformDetails: ?PlatformDetails, ): RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo { - if (hasMinCodeVersion(platformDetails, 75)) { + if (hasMinCodeVersion(platformDetails, { native: 75 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'created a thread', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawCreateSidebarMessageInfo, ): RawCreateSidebarMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): Promise { const createSidebarMessageInfo = messageInfos[0]; invariant( createSidebarMessageInfo.type === messageTypes.CREATE_SIDEBAR, 'first MessageInfo should be messageTypes.CREATE_SIDEBAR!', ); let sidebarSourceMessageInfo; const secondMessageInfo = messageInfos[1]; if ( secondMessageInfo && secondMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { sidebarSourceMessageInfo = secondMessageInfo; } return notifTextsForSidebarCreation({ createSidebarMessageInfo, sidebarSourceMessageInfo, threadInfo, params, }); }, notificationCollapseKey(rawMessageInfo: RawCreateSidebarMessageInfo): string { return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, userIDs(rawMessageInfo: RawCreateSidebarMessageInfo): $ReadOnlyArray { return rawMessageInfo.initialThreadState.memberIDs; }, threadIDs( rawMessageInfo: RawCreateSidebarMessageInfo, ): $ReadOnlyArray { const { parentThreadID } = rawMessageInfo.initialThreadState; return [parentThreadID]; }, validator: rawCreateSidebarMessageInfoValidator, }); diff --git a/lib/shared/messages/edit-message-spec.js b/lib/shared/messages/edit-message-spec.js index 3e7768356..8de9d7191 100644 --- a/lib/shared/messages/edit-message-spec.js +++ b/lib/shared/messages/edit-message-spec.js @@ -1,146 +1,146 @@ // @flow import invariant from 'invariant'; import { type MessageSpec, type MessageTitleParam } from './message-spec.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import { type ClientDBMessageInfo } from '../../types/message-types.js'; import { type EditMessageData, type RawEditMessageInfo, type EditMessageInfo, rawEditMessageInfoValidator, } from '../../types/messages/edit.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const editMessageSpec: MessageSpec< EditMessageData, RawEditMessageInfo, EditMessageInfo, > = Object.freeze({ messageContentForServerDB( data: EditMessageData | RawEditMessageInfo, ): string { return JSON.stringify({ text: data.text, }); }, messageContentForClientDB(data: RawEditMessageInfo): string { return JSON.stringify({ targetMessageID: data.targetMessageID, text: data.text, }); }, messageTitle({ messageInfo }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} edited a message`; }, rawMessageInfoFromServerDBRow(row: Object): RawEditMessageInfo { invariant( row.targetMessageID !== null && row.targetMessageID !== undefined, 'targetMessageID should be set', ); const content = JSON.parse(row.content); return { type: messageTypes.EDIT_MESSAGE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetMessageID: row.targetMessageID.toString(), text: content.text, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawEditMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( messageType === messageTypes.EDIT_MESSAGE, 'message must be of type EDIT_MESSAGE', ); invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for EditMessage', ); const content = JSON.parse(clientDBMessageInfo.content); const rawEditMessageInfo: RawEditMessageInfo = { type: messageTypes.EDIT_MESSAGE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetMessageID: content.targetMessageID, text: content.text, }; return rawEditMessageInfo; }, createMessageInfo( rawMessageInfo: RawEditMessageInfo, creator: RelativeUserInfo, ): EditMessageInfo { return { type: messageTypes.EDIT_MESSAGE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, targetMessageID: rawMessageInfo.targetMessageID, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: EditMessageData, id: ?string, ): RawEditMessageInfo { invariant(id, 'RawEditMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawEditMessageInfo, platformDetails: ?PlatformDetails, ): RawEditMessageInfo | RawUnsupportedMessageInfo { - if (hasMinCodeVersion(platformDetails, 215)) { + if (hasMinCodeVersion(platformDetails, { native: 215 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'edited a message', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo(unwrapped: RawEditMessageInfo): RawEditMessageInfo { return unwrapped; }, generatesNotifs: async () => undefined, validator: rawEditMessageInfoValidator, }); diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index 8ff07d50d..f94a32c67 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,355 +1,358 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { isMediaBlobServiceHosted, contentStringForMediaArray, multimediaMessagePreview, } from '../../media/media-utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import { isMediaMessageType, rawMultimediaMessageInfoValidator, } from '../../types/message-types.js'; import type { MessageInfo, RawMessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images.js'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils.js'; import { createMediaMessageInfo } from '../message-utils.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { FUTURE_CODE_VERSION, hasMinCodeVersion } from '../version-utils.js'; export const multimediaMessageSpec: MessageSpec< MediaMessageData | ImagesMessageData, RawMediaMessageInfo | RawImagesMessageInfo, MediaMessageInfo | ImagesMessageInfo, > = Object.freeze({ messageContentForServerDB( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ): string { if (data.type === messageTypes.MULTIMEDIA) { return JSON.stringify( getMediaMessageServerDBContentsFromMedia(data.media), ); } const mediaIDs = data.media.map(media => parseInt(media.id, 10)); return JSON.stringify(mediaIDs); }, messageContentForClientDB( data: RawMediaMessageInfo | RawImagesMessageInfo, ): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawImagesMessageInfo | RawMediaMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( isMediaMessageType(messageType), 'message must be of type IMAGES or MULTIMEDIA', ); invariant( clientDBMessageInfo.media_infos !== null && clientDBMessageInfo.media_infos !== undefined, `media_infos must be defined`, ); let rawMessageInfo: RawImagesMessageInfo | RawMediaMessageInfo = messageType === messageTypes.IMAGES ? { type: messageTypes.IMAGES, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: clientDBMessageInfo.media_infos?.map( translateClientDBMediaInfoToImage, ) ?? [], } : { type: messageTypes.MULTIMEDIA, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: translateClientDBMediaInfosToMedia(clientDBMessageInfo), }; if (clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, id: clientDBMessageInfo.id, }; } return rawMessageInfo; }, messageTitle({ messageInfo, }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); const preview = multimediaMessagePreview(messageInfo); return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawMediaMessageInfo | RawImagesMessageInfo { const { localID, media } = params; invariant(media, 'Media should be provided'); return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID, time: row.time, }); }, createMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, creator: RelativeUserInfo, ): ?(MediaMessageInfo | ImagesMessageInfo) { if (rawMessageInfo.type === messageTypes.IMAGES) { let messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { let messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } return undefined; }, rawMessageInfoFromMessageData( messageData: MediaMessageData | ImagesMessageData, id: ?string, ): RawMediaMessageInfo | RawImagesMessageInfo { const { sidebarCreation, ...rest } = messageData; if (rest.type === messageTypes.IMAGES && id) { return ({ ...rest, id }: RawImagesMessageInfo); } else if (rest.type === messageTypes.IMAGES) { return ({ ...rest }: RawImagesMessageInfo); } else if (id) { return ({ ...rest, id }: RawMediaMessageInfo); } else { return ({ ...rest }: RawMediaMessageInfo); } }, shimUnsupportedMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, platformDetails: ?PlatformDetails, ): RawMediaMessageInfo | RawImagesMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { return rawMessageInfo; } const containsBlobServiceMedia = rawMessageInfo.media.some( isMediaBlobServiceHosted, ); const containsEncryptedMedia = rawMessageInfo.media.some( media => media.type === 'encrypted_photo' || media.type === 'encrypted_video', ); if ( !containsBlobServiceMedia && !containsEncryptedMedia && - hasMinCodeVersion(platformDetails, 158) + hasMinCodeVersion(platformDetails, { native: 158 }) ) { return rawMessageInfo; } - if (!containsBlobServiceMedia && hasMinCodeVersion(platformDetails, 205)) { + if ( + !containsBlobServiceMedia && + hasMinCodeVersion(platformDetails, { native: 205 }) + ) { return rawMessageInfo; } - if (hasMinCodeVersion(platformDetails, FUTURE_CODE_VERSION)) { + if (hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: multimediaMessagePreview(rawMessageInfo), unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawMediaMessageInfo | RawImagesMessageInfo, messageInfo: RawMessageInfo, ): ?RawMessageInfo { if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map(media => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } else if (unwrapped.type === messageTypes.MULTIMEDIA) { for (const media of unwrapped.media) { if (isMediaBlobServiceHosted(media)) { return messageInfo; } const { type } = media; if ( type !== 'photo' && type !== 'video' && type !== 'encrypted_photo' && type !== 'encrypted_video' ) { return messageInfo; } } } return undefined; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const media = []; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (const singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const creator = ET.user({ userInfo: messageInfos[0].creator }); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${body} to ${thread}`; } merged = ET`${creator} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, notificationCollapseKey( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, ): string { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async ( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, messageData: MediaMessageData | ImagesMessageData, ) => (messageData.sidebarCreation ? undefined : pushTypes.NOTIF), includedInRepliesCount: true, validator: rawMultimediaMessageInfoValidator, }); // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, }; diff --git a/lib/shared/messages/reaction-message-spec.js b/lib/shared/messages/reaction-message-spec.js index ec5d7931d..b2c9d45ff 100644 --- a/lib/shared/messages/reaction-message-spec.js +++ b/lib/shared/messages/reaction-message-spec.js @@ -1,222 +1,222 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type GeneratesNotifsParams, } from './message-spec.js'; import { assertSingleMessageInfo, joinResult } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import { type MessageInfo, type ClientDBMessageInfo, } from '../../types/message-types.js'; import { type ReactionMessageData, type RawReactionMessageInfo, type ReactionMessageInfo, rawReactionMessageInfoValidator, } from '../../types/messages/reaction.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const reactionMessageSpec: MessageSpec< ReactionMessageData, RawReactionMessageInfo, ReactionMessageInfo, > = Object.freeze({ messageContentForServerDB( data: ReactionMessageData | RawReactionMessageInfo, ): string { return JSON.stringify({ reaction: data.reaction, action: data.action, }); }, messageContentForClientDB(data: RawReactionMessageInfo): string { return JSON.stringify({ targetMessageID: data.targetMessageID, reaction: data.reaction, action: data.action, }); }, messageTitle({ messageInfo }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); const preview = messageInfo.action === 'add_reaction' ? `reacted ${messageInfo.reaction} to a message` : 'unreacted to a message'; return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow(row: Object): RawReactionMessageInfo { invariant( row.targetMessageID !== null && row.targetMessageID !== undefined, 'targetMessageID should be set', ); const content = JSON.parse(row.content); return { type: messageTypes.REACTION, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetMessageID: row.targetMessageID.toString(), reaction: content.reaction, action: content.action, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawReactionMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( messageType === messageTypes.REACTION, 'message must be of type REACTION', ); invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for Reaction', ); const content = JSON.parse(clientDBMessageInfo.content); const rawReactionMessageInfo: RawReactionMessageInfo = { type: messageTypes.REACTION, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetMessageID: content.targetMessageID, reaction: content.reaction, action: content.action, }; return rawReactionMessageInfo; }, createMessageInfo( rawMessageInfo: RawReactionMessageInfo, creator: RelativeUserInfo, ): ReactionMessageInfo { return { type: messageTypes.REACTION, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, targetMessageID: rawMessageInfo.targetMessageID, reaction: rawMessageInfo.reaction, action: rawMessageInfo.action, }; }, rawMessageInfoFromMessageData( messageData: ReactionMessageData, id: ?string, ): RawReactionMessageInfo { invariant(id, 'RawReactionMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawReactionMessageInfo, platformDetails: ?PlatformDetails, ): RawReactionMessageInfo | RawUnsupportedMessageInfo { - if (hasMinCodeVersion(platformDetails, 167)) { + if (hasMinCodeVersion(platformDetails, { native: 167 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'reacted to a message', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo(unwrapped: RawReactionMessageInfo): RawReactionMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.REACTION, 'messageInfo should be reaction type', ); const creator = ET.user({ userInfo: messageInfo.creator }); const body = messageInfo.action === 'add_reaction' ? `reacted ${messageInfo.reaction} to your message` : 'unreacted to your message'; let merged = ET`${creator} ${body}`; if (threadInfo.name || threadIsGroupChat(threadInfo)) { const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${merged} in ${thread}`; } return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, notificationCollapseKey(rawMessageInfo: RawReactionMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.targetMessageID, ); }, generatesNotifs: async ( rawMessageInfo: RawReactionMessageInfo, messageData: ReactionMessageData, params: GeneratesNotifsParams, ) => { const { action } = rawMessageInfo; const { notifTargetUserID, fetchMessageInfoByID } = params; const targetMessageInfo = await fetchMessageInfoByID( rawMessageInfo.targetMessageID, ); if (targetMessageInfo?.creatorID !== notifTargetUserID) { return undefined; } return action === 'add_reaction' ? pushTypes.NOTIF : pushTypes.RESCIND; }, validator: rawReactionMessageInfoValidator, }); diff --git a/lib/shared/messages/sidebar-source-message-spec.js b/lib/shared/messages/sidebar-source-message-spec.js index abe54ec70..5c9191c15 100644 --- a/lib/shared/messages/sidebar-source-message-spec.js +++ b/lib/shared/messages/sidebar-source-message-spec.js @@ -1,179 +1,179 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { type RawSidebarSourceMessageInfo, type SidebarSourceMessageData, type SidebarSourceMessageInfo, type ClientDBMessageInfo, isMessageSidebarSourceReactionOrEdit, rawSidebarSourceMessageInfoValidator, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const sidebarSourceMessageSpec: MessageSpec< SidebarSourceMessageData, RawSidebarSourceMessageInfo, SidebarSourceMessageInfo, > = Object.freeze({ messageContentForServerDB( data: SidebarSourceMessageData | RawSidebarSourceMessageInfo, ): string { const sourceMessageID = data.sourceMessage?.id; invariant(sourceMessageID, 'Source message id should be set'); return JSON.stringify({ sourceMessageID, }); }, messageContentForClientDB(data: RawSidebarSourceMessageInfo): string { invariant( data.sourceMessage && data.sourceMessage.id, 'sourceMessage and sourceMessage.id should be defined', ); return JSON.stringify(data.sourceMessage); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawSidebarSourceMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for SidebarSource', ); const sourceMessage = JSON.parse(clientDBMessageInfo.content); const rawSidebarSourceMessageInfo: RawSidebarSourceMessageInfo = { type: messageTypes.SIDEBAR_SOURCE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessage, }; return rawSidebarSourceMessageInfo; }, messageTitle() { invariant(false, 'Cannot call messageTitle on sidebarSourceMessageSpec'); }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawSidebarSourceMessageInfo { const { derivedMessages } = params; invariant(derivedMessages, 'Derived messages should be provided'); const content = JSON.parse(row.content); const sourceMessage = derivedMessages.get(content.sourceMessageID); if (!sourceMessage) { console.warn( `Message with id ${row.id} has a derived message ` + `${content.sourceMessageID} which is not present in the database`, ); } return { type: messageTypes.SIDEBAR_SOURCE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessage, }; }, createMessageInfo( rawMessageInfo: RawSidebarSourceMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?SidebarSourceMessageInfo { if (!rawMessageInfo.sourceMessage) { return null; } const sourceMessage = params.createMessageInfoFromRaw( rawMessageInfo.sourceMessage, ); invariant( sourceMessage && !isMessageSidebarSourceReactionOrEdit(sourceMessage), 'Sidebars can not be created from SIDEBAR SOURCE, REACTION or EDIT MESSAGE', ); return { type: messageTypes.SIDEBAR_SOURCE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessage, }; }, rawMessageInfoFromMessageData( messageData: SidebarSourceMessageData, id: ?string, ): RawSidebarSourceMessageInfo { invariant(id, 'RawSidebarSourceMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawSidebarSourceMessageInfo, platformDetails: ?PlatformDetails, ): RawSidebarSourceMessageInfo | RawUnsupportedMessageInfo { if ( - hasMinCodeVersion(platformDetails, 75) && + hasMinCodeVersion(platformDetails, { native: 75 }) && rawMessageInfo.sourceMessage ) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'first message in thread', dontPrefixCreator: true, unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawSidebarSourceMessageInfo, ): RawSidebarSourceMessageInfo { return unwrapped; }, notificationTexts(): Promise { invariant( false, 'SIDEBAR_SOURCE notificationTexts should never be called directly!', ); }, notificationCollapseKey(rawMessageInfo: RawSidebarSourceMessageInfo): string { return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, startsThread: true, validator: rawSidebarSourceMessageInfoValidator, }); diff --git a/lib/shared/messages/toggle-pin-message-spec.js b/lib/shared/messages/toggle-pin-message-spec.js index 30d49e6ef..93ea7ff0f 100644 --- a/lib/shared/messages/toggle-pin-message-spec.js +++ b/lib/shared/messages/toggle-pin-message-spec.js @@ -1,161 +1,161 @@ // @flow import invariant from 'invariant'; import type { MessageSpec, RobotextParams, RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import type { PlatformDetails } from '../../types/device-types'; import { messageTypes } from '../../types/message-types-enum.js'; import type { ClientDBMessageInfo } from '../../types/message-types.js'; import { type TogglePinMessageData, type TogglePinMessageInfo, type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, } from '../../types/messages/toggle-pin.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { getPinnedContentFromClientDBMessageInfo } from '../../utils/message-ops-utils.js'; import { getPinnedContentFromMessage } from '../message-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const togglePinMessageSpec: MessageSpec< TogglePinMessageData, RawTogglePinMessageInfo, TogglePinMessageInfo, > = Object.freeze({ messageContentForServerDB( data: TogglePinMessageData | RawTogglePinMessageInfo, ): string { return JSON.stringify({ action: data.action, threadID: data.threadID, targetMessageID: data.targetMessageID, }); }, messageContentForClientDB(data: RawTogglePinMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTogglePinMessageInfo { const content = JSON.parse(row.content); const { derivedMessages } = params; const targetMessage = derivedMessages.get(content.targetMessageID); invariant(targetMessage, 'targetMessage should be defined'); return { type: messageTypes.TOGGLE_PIN, id: row.id.toString(), threadID: row.threadID.toString(), targetMessageID: content.targetMessageID.toString(), action: content.action, pinnedContent: getPinnedContentFromMessage(targetMessage), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTogglePinMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for TogglePin', ); const content = JSON.parse(clientDBMessageInfo.content); const pinnedContent = getPinnedContentFromClientDBMessageInfo(clientDBMessageInfo); const rawTogglePinMessageInfo: RawTogglePinMessageInfo = { type: messageTypes.TOGGLE_PIN, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, targetMessageID: content.targetMessageID, action: content.action, pinnedContent, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawTogglePinMessageInfo; }, createMessageInfo( rawMessageInfo: RawTogglePinMessageInfo, creator: RelativeUserInfo, ): TogglePinMessageInfo { return { type: messageTypes.TOGGLE_PIN, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, targetMessageID: rawMessageInfo.targetMessageID, action: rawMessageInfo.action, pinnedContent: rawMessageInfo.pinnedContent, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: TogglePinMessageData, id: ?string, ): RawTogglePinMessageInfo { invariant(id, 'RawTogglePinMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: TogglePinMessageInfo, params: RobotextParams, ): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); const action = messageInfo.action === 'pin' ? 'pinned' : 'unpinned'; const pinnedContent = messageInfo.pinnedContent; const preposition = messageInfo.action === 'pin' ? 'in' : 'from'; return ET`${creator} ${action} ${pinnedContent} ${preposition} ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, threadType: params.threadInfo?.type, parentThreadID: params.threadInfo?.parentThreadID, })}`; }, shimUnsupportedMessageInfo( rawMessageInfo: RawTogglePinMessageInfo, platformDetails: ?PlatformDetails, ): RawTogglePinMessageInfo | RawUnsupportedMessageInfo { - if (hasMinCodeVersion(platformDetails, 209)) { + if (hasMinCodeVersion(platformDetails, { native: 209 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'toggled a message pin', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawTogglePinMessageInfo, ): RawTogglePinMessageInfo { return unwrapped; }, generatesNotifs: async () => undefined, validator: rawTogglePinMessageInfoValidator, }); diff --git a/lib/shared/messages/update-relationship-message-spec.js b/lib/shared/messages/update-relationship-message-spec.js index 7dda38747..5e86c2854 100644 --- a/lib/shared/messages/update-relationship-message-spec.js +++ b/lib/shared/messages/update-relationship-message-spec.js @@ -1,181 +1,181 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import { type RawUpdateRelationshipMessageInfo, type UpdateRelationshipMessageData, type UpdateRelationshipMessageInfo, rawUpdateRelationshipMessageInfoValidator, } from '../../types/messages/update-relationship.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET, type EntityText } from '../../utils/entity-text.js'; import { hasMinCodeVersion } from '../version-utils.js'; export const updateRelationshipMessageSpec: MessageSpec< UpdateRelationshipMessageData, RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageInfo, > = Object.freeze({ messageContentForServerDB( data: UpdateRelationshipMessageData | RawUpdateRelationshipMessageInfo, ): string { return JSON.stringify({ operation: data.operation, targetID: data.targetID, }); }, messageContentForClientDB(data: RawUpdateRelationshipMessageInfo): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromServerDBRow(row: Object): RawUpdateRelationshipMessageInfo { const content = JSON.parse(row.content); return { type: messageTypes.UPDATE_RELATIONSHIP, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetID: content.targetID, operation: content.operation, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawUpdateRelationshipMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for UpdateRelationship', ); const content = JSON.parse(clientDBMessageInfo.content); const rawUpdateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { type: messageTypes.UPDATE_RELATIONSHIP, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetID: content.targetID, operation: content.operation, }; return rawUpdateRelationshipMessageInfo; }, createMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?UpdateRelationshipMessageInfo { const target = params.createRelativeUserInfos([rawMessageInfo.targetID])[0]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, target, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; }, rawMessageInfoFromMessageData( messageData: UpdateRelationshipMessageData, id: ?string, ): RawUpdateRelationshipMessageInfo { invariant(id, 'RawUpdateRelationshipMessageInfo needs id'); return { ...messageData, id }; }, // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return robotext(messageInfo: UpdateRelationshipMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); if (messageInfo.operation === 'request_sent') { const target = ET.user({ userInfo: messageInfo.target }); return ET`${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = ET.user({ userInfo: messageInfo.target, possessive: true, }); return ET`${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); }, shimUnsupportedMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, platformDetails: ?PlatformDetails, ): RawUpdateRelationshipMessageInfo | RawUnsupportedMessageInfo { - if (hasMinCodeVersion(platformDetails, 71)) { + if (hasMinCodeVersion(platformDetails, { native: 71 })) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'performed a relationship action', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawUpdateRelationshipMessageInfo, ): RawUpdateRelationshipMessageInfo { return unwrapped; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = ET`${prefix} ${body}`; return { merged, body, title, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, validator: rawUpdateRelationshipMessageInfoValidator, }); diff --git a/lib/shared/version-utils.js b/lib/shared/version-utils.js index 473d890f1..1e5a5bfb1 100644 --- a/lib/shared/version-utils.js +++ b/lib/shared/version-utils.js @@ -1,25 +1,33 @@ // @flow import { type PlatformDetails, isWebPlatform } from '../types/device-types.js'; /** * A code version used for features that are waiting to be released * and we're not sure in which version they will be available. */ const FUTURE_CODE_VERSION = 1000000; function hasMinCodeVersion( platformDetails: ?PlatformDetails, - minCodeVersion: number, + minCodeVersion: $Shape<{ +native: number, +web: number }>, ): boolean { - if (!platformDetails || isWebPlatform(platformDetails.platform)) { + if (!platformDetails) { return true; } const { codeVersion } = platformDetails; - if (!codeVersion || codeVersion < minCodeVersion) { + const minVersion = isWebPlatform(platformDetails.platform) + ? minCodeVersion.web + : minCodeVersion.native; + + if (!minVersion) { + return true; + } + if (!codeVersion || codeVersion < minVersion) { return false; } + return true; } export { FUTURE_CODE_VERSION, hasMinCodeVersion };