diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index 39d47bfd7..781980c4c 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,508 +1,480 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { generatePendingThreadColor, generateRandomColor, getThreadTypeParentRequirement, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { Shape } from 'lib/types/core'; import { messageTypes } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type NewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { firstLine } from 'lib/utils/string-utils'; import { dbQuery, SQL } from '../database/database'; import { fetchMessageInfoByID } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, setJoinsToUnread, - getRelationshipRowsForUsers, - getParentThreadRelationshipRowsForNewUsers, } from '../updaters/thread-permission-updaters'; +import RelationshipChangeset from '../utils/relationship-changeset'; import createIDs from './id-creator'; import createMessages from './message-creator'; import { createInitialRolesForNewThread } from './role-creator'; import type { UpdatesForCurrentSession } from './update-creator'; const { squadbot } = bots; const privateThreadDescription = 'This is your private thread, ' + '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: NewThreadRequest, 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; const parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? request.initialMemberIDs : null; const ghostMemberIDs = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? 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 checkPromises = {}; if (parentThreadID) { checkPromises.parentThreadFetch = fetchThreadInfos( viewer, SQL`t.id = ${parentThreadID}`, ); checkPromises.hasParentPermission = checkThreadPermission( viewer, parentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBTHREADS, ); } const memberIDs = []; if (initialMemberIDsFromRequest) { memberIDs.push(...initialMemberIDsFromRequest); } if (ghostMemberIDs) { memberIDs.push(...ghostMemberIDs); } if (initialMemberIDsFromRequest || ghostMemberIDs) { checkPromises.fetchMemberIDs = fetchKnownUserInfos(viewer, memberIDs); } if (sourceMessageID) { checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); } const { parentThreadFetch, hasParentPermission, fetchMemberIDs, sourceMessage, } = await promiseAll(checkPromises); let parentThreadMembers; if (parentThreadID) { invariant(parentThreadFetch, 'parentThreadFetch should be set'); const parentThreadInfo = parentThreadFetch.threadInfos[parentThreadID]; if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } parentThreadMembers = parentThreadInfo.members.map( (userInfo) => userInfo.id, ); } - const viewerNeedsRelationshipsWith = []; + const relationshipChangeset = new RelationshipChangeset(); const silencedMemberIDs = new Set(); if (fetchMemberIDs) { invariant(initialMemberIDsFromRequest || ghostMemberIDs, 'should be set'); for (const memberID of memberIDs) { const member = fetchMemberIDs[memberID]; if ( !member && shouldCreateRelationships && (threadType !== threadTypes.SIDEBAR || parentThreadMembers?.includes(memberID)) ) { - viewerNeedsRelationshipsWith.push(memberID); continue; } else if (!member && silentlyFailMembers) { silencedMemberIDs.add(memberID); continue; } else if (!member) { throw new ServerError('invalid_credentials'); } + relationshipChangeset.setRelationshipExists(viewer.id, memberID); const { relationshipStatus } = member; const memberRelationshipHasBlock = !!( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ); if ( relationshipStatus === userRelationshipStatus.FRIEND && threadType !== threadTypes.SIDEBAR ) { continue; } else if (memberRelationshipHasBlock && silentlyFailMembers) { silencedMemberIDs.add(memberID); } else if (memberRelationshipHasBlock) { throw new ServerError('invalid_credentials'); } else if ( parentThreadMembers && parentThreadMembers.includes(memberID) ) { continue; } else if (!shouldCreateRelationships && silentlyFailMembers) { silencedMemberIDs.add(memberID); } else if (!shouldCreateRelationships) { throw new ServerError('invalid_credentials'); } } } const filteredInitialMemberIDs: ?$ReadOnlyArray = initialMemberIDsFromRequest?.filter( (id) => !silencedMemberIDs.has(id), ); const initialMemberIDs = filteredInitialMemberIDs && filteredInitialMemberIDs.length > 0 ? filteredInitialMemberIDs : null; const [id] = await createIDs('threads', 1); 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, 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 = SQL` SELECT t.id FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${otherMemberID} WHERE t.type = ${threadTypes.PERSONAL} AND m1.role != -1 AND m2.role != -1 `; } 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, 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(); 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, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberIDs ? changeRole(id, initialMemberIDs, null) : undefined, ghostMemberIDs ? changeRole(id, ghostMemberIDs, -1) : undefined, recalculateThreadPermissions(id, threadType), ]); if (!creatorChangeset) { throw new ServerError('unknown_error'); } const { membershipRows: creatorMembershipRows, - relationshipRows: creatorRelationshipRows, + relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, - relationshipRows: recalculateRelationshipRows, + relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; - const relationshipRows = [ - ...creatorRelationshipRows, - ...recalculateRelationshipRows, - ]; - if (initialMemberIDs || ghostMemberIDs) { - if (!initialMembersChangeset && !ghostMembersChangeset) { - throw new ServerError('unknown_error'); - } - relationshipRows.push( - ...getRelationshipRowsForUsers( - viewer.userID, - viewerNeedsRelationshipsWith, - ), - ); - const membersMembershipRows = []; - const membersRelationshipRows = []; - if (initialMembersChangeset) { - const { - membershipRows: initialMembersMembershipRows, - relationshipRows: initialMembersRelationshipRows, - } = initialMembersChangeset; - membersMembershipRows.push(...initialMembersMembershipRows); - membersRelationshipRows.push(...initialMembersRelationshipRows); - } - - if (ghostMembersChangeset) { - const { - membershipRows: ghostMembersMembershipRows, - relationshipRows: ghostMembersRelationshipRows, - } = ghostMembersChangeset; - membersMembershipRows.push(...ghostMembersMembershipRows); - membersRelationshipRows.push(...ghostMembersRelationshipRows); - } - - const memberAndCreatorIDs = [...memberIDs, viewer.userID]; - const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( - id, - recalculateMembershipRows, - memberAndCreatorIDs, - ); - membershipRows.push(...membersMembershipRows); - relationshipRows.push( - ...membersRelationshipRows, - ...parentRelationshipRows, - ); + relationshipChangeset.addAll(creatorRelationshipChangeset); + relationshipChangeset.addAll(recalculateRelationshipChangeset); + + if (initialMembersChangeset) { + const { + membershipRows: initialMembersMembershipRows, + relationshipChangeset: initialMembersRelationshipChangeset, + } = initialMembersChangeset; + membershipRows.push(...initialMembersMembershipRows); + relationshipChangeset.addAll(initialMembersRelationshipChangeset); + } + if (ghostMembersChangeset) { + const { + membershipRows: ghostMembersMembershipRows, + relationshipChangeset: ghostMembersRelationshipChangeset, + } = ghostMembersChangeset; + membershipRows.push(...ghostMembersMembershipRows); + relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } setJoinsToUnread(membershipRows, viewer.userID, id); - const changeset = { membershipRows, relationshipRows }; + 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'); } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if (parentThreadID && threadType !== threadTypes.SIDEBAR) { 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)) { 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: [squadbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread, privateThreadDescription }; diff --git a/server/src/scripts/merge-users.js b/server/src/scripts/merge-users.js index e9cb98660..5723ee600 100644 --- a/server/src/scripts/merge-users.js +++ b/server/src/scripts/merge-users.js @@ -1,200 +1,201 @@ // @flow import type { Shape } from 'lib/types/core'; import type { ServerThreadInfo } from 'lib/types/thread-types'; import { type UpdateData, updateTypes } from 'lib/types/update-types'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL, SQLStatement } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers'; import { createScriptViewer } from '../session/scripts'; import { changeRole, commitMembershipChangeset, } from '../updaters/thread-permission-updaters'; +import RelationshipChangeset from '../utils/relationship-changeset'; import { endScript } from './utils'; async function main() { try { await mergeUsers('7147', '15972', { username: true, password: true }); endScript(); } catch (e) { endScript(); console.warn(e); } } type ReplaceUserInfo = Shape<{| +username: boolean, +email: boolean, +password: boolean, |}>; async function mergeUsers( fromUserID: string, toUserID: string, replaceUserInfo?: ReplaceUserInfo, ) { let updateUserRowQuery = null; let updateDatas = []; if (replaceUserInfo) { const replaceUserResult = await replaceUser( fromUserID, toUserID, replaceUserInfo, ); ({ sql: updateUserRowQuery, updateDatas } = replaceUserResult); } const usersGettingUpdate = new Set(); const usersNeedingUpdate = new Set(); const needUserInfoUpdate = replaceUserInfo && replaceUserInfo.username; const setGettingUpdate = (threadInfo: ServerThreadInfo) => { if (!needUserInfoUpdate) { return; } for (const { id } of threadInfo.members) { usersGettingUpdate.add(id); usersNeedingUpdate.delete(id); } }; const setNeedingUpdate = (threadInfo: ServerThreadInfo) => { if (!needUserInfoUpdate) { return; } for (const { id } of threadInfo.members) { if (!usersGettingUpdate.has(id)) { usersNeedingUpdate.add(id); } } }; const newThreadRolePairs = []; const { threadInfos } = await fetchServerThreadInfos(); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const fromUserExistingMember = threadInfo.members.find( (memberInfo) => memberInfo.id === fromUserID, ); if (!fromUserExistingMember) { setNeedingUpdate(threadInfo); continue; } const { role } = fromUserExistingMember; if (!role) { // Only transfer explicit memberships setNeedingUpdate(threadInfo); continue; } const toUserExistingMember = threadInfo.members.find( (memberInfo) => memberInfo.id === toUserID, ); if (!toUserExistingMember || !toUserExistingMember.role) { setGettingUpdate(threadInfo); newThreadRolePairs.push([threadID, role]); } else { setNeedingUpdate(threadInfo); } } const fromViewer = createScriptViewer(fromUserID); await deleteAccount(fromViewer); if (updateUserRowQuery) { await dbQuery(updateUserRowQuery); } const time = Date.now(); for (const userID of usersNeedingUpdate) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID, time, updatedUserID: toUserID, }); } await createUpdates(updateDatas); const changesets = await Promise.all( newThreadRolePairs.map(([threadID, role]) => changeRole(threadID, [toUserID], role), ), ); const membershipRows = []; - const relationshipRows = []; + const relationshipChangeset = new RelationshipChangeset(); for (const currentChangeset of changesets) { if (!currentChangeset) { throw new Error('changeRole returned null'); } const { membershipRows: currentMembershipRows, - relationshipRows: currentRelationshipRows, + relationshipChangeset: currentRelationshipChangeset, } = currentChangeset; membershipRows.push(...currentMembershipRows); - relationshipRows.push(...currentRelationshipRows); + relationshipChangeset.addAll(currentRelationshipChangeset); } - if (membershipRows.length > 0 || relationshipRows.length > 0) { + if (membershipRows.length > 0 || relationshipChangeset.getRowCount() > 0) { const toViewer = createScriptViewer(toUserID); - const changeset = { membershipRows, relationshipRows }; + const changeset = { membershipRows, relationshipChangeset }; await commitMembershipChangeset(toViewer, changeset); } } type ReplaceUserResult = {| sql: ?SQLStatement, updateDatas: UpdateData[], |}; async function replaceUser( fromUserID: string, toUserID: string, replaceUserInfo: ReplaceUserInfo, ): Promise { if (Object.keys(replaceUserInfo).length === 0) { return { sql: null, updateDatas: [], }; } const fromUserQuery = SQL` SELECT username, hash, email, email_verified FROM users WHERE id = ${fromUserID} `; const [fromUserResult] = await dbQuery(fromUserQuery); const [firstResult] = fromUserResult; if (!firstResult) { throw new Error(`couldn't fetch fromUserID ${fromUserID}`); } const changedFields = {}; if (replaceUserInfo.username) { changedFields.username = firstResult.username; } if (replaceUserInfo.email) { changedFields.email = firstResult.email; changedFields.email_verified = firstResult.email_verified; } if (replaceUserInfo.password) { changedFields.hash = firstResult.hash; } const updateUserRowQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${toUserID} `; const updateDatas = []; if (replaceUserInfo.username || replaceUserInfo.email) { updateDatas.push({ type: updateTypes.UPDATE_CURRENT_USER, userID: toUserID, time: Date.now(), }); } return { sql: updateUserRowQuery, updateDatas, }; } main(); diff --git a/server/src/updaters/thread-permission-updaters.js b/server/src/updaters/thread-permission-updaters.js index 6d4114223..c396c9a26 100644 --- a/server/src/updaters/thread-permission-updaters.js +++ b/server/src/updaters/thread-permission-updaters.js @@ -1,840 +1,800 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; -import _uniqWith from 'lodash/fp/uniqWith'; import bots from 'lib/facts/bots'; import { makePermissionsBlob, makePermissionsForChildrenBlob, } from 'lib/permissions/thread-permissions'; -import { sortIDs } from 'lib/shared/relationship-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; -import { - type UndirectedRelationshipRow, - undirectedStatus, -} from 'lib/types/relationship-types'; import type { ThreadSubscription } from 'lib/types/subscription-types'; import { type ThreadPermissionsBlob, type ThreadRolePermissionsBlob, type ThreadType, assertThreadType, } from 'lib/types/thread-types'; import { updateTypes, type UpdateInfo } from 'lib/types/update-types'; import type { AccountUserInfo } from 'lib/types/user-types'; -import { cartesianProduct } from 'lib/utils/array'; import { ServerError } from 'lib/utils/errors'; import { createUpdates, type UpdatesForCurrentSession, } from '../creators/update-creator'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import { fetchServerThreadInfos, rawThreadInfosFromServerThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers'; import { rescindPushNotifs } from '../push/rescind'; import { createScriptViewer } from '../session/scripts'; import type { Viewer } from '../session/viewer'; +import RelationshipChangeset from '../utils/relationship-changeset'; import { updateDatasForUserPairs, updateUndirectedRelationships, } from './relationship-updaters'; export type MembershipRowToSave = {| - operation: 'update' | 'join', - userID: string, - threadID: string, - permissions: ?ThreadPermissionsBlob, - permissionsForChildren: ?ThreadPermissionsBlob, + +operation: 'update' | 'join', + +userID: string, + +threadID: string, + +permissions: ?ThreadPermissionsBlob, + +permissionsForChildren: ?ThreadPermissionsBlob, // null role represents by "0" - role: string, - subscription?: ThreadSubscription, + +role: string, + +subscription?: ThreadSubscription, lastMessage?: number, lastReadMessage?: number, |}; type MembershipRowToDelete = {| - operation: 'delete', - userID: string, - threadID: string, - forceRowCreation?: boolean, + +operation: 'delete', + +userID: string, + +threadID: string, + +forceRowCreation?: boolean, |}; type MembershipRow = MembershipRowToSave | MembershipRowToDelete; type Changeset = {| - membershipRows: MembershipRow[], - relationshipRows: UndirectedRelationshipRow[], + +membershipRows: MembershipRow[], + +relationshipChangeset: RelationshipChangeset, |}; // 0 role means to remove the user from the thread // null role means to set the user to the default role // string role means to set the user to the role with that ID async function changeRole( threadID: string, userIDs: $ReadOnlyArray, role: string | -1 | 0 | null, ): Promise { const membershipQuery = SQL` SELECT m.user, m.role, m.permissions_for_children, pm.permissions_for_children AS permissions_from_parent FROM memberships m LEFT JOIN threads t ON t.id = m.thread LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user WHERE m.thread = ${threadID} `; const [[membershipResult], roleThreadResult] = await Promise.all([ dbQuery(membershipQuery), changeRoleThreadQuery(threadID, role), ]); if (!roleThreadResult) { return null; } const roleInfo = new Map(); for (const row of membershipResult) { const userID = row.user.toString(); const oldPermissionsForChildren = row.permissions_for_children; const permissionsFromParent = row.permissions_from_parent; roleInfo.set(userID, { oldRole: row.role.toString(), oldPermissionsForChildren, permissionsFromParent, }); } - const relationshipRows = []; const membershipRows = []; + const relationshipChangeset = new RelationshipChangeset(); const toUpdateDescendants = new Map(); - const memberIDs = new Set(roleInfo.keys()); + const existingMemberIDs = [...new Set(roleInfo.keys())]; + relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); for (const userID of userIDs) { let oldPermissionsForChildren = null; let permissionsFromParent = null; let hadMembershipRow = false; const userRoleInfo = roleInfo.get(userID); if (userRoleInfo) { const oldRole = userRoleInfo.oldRole; if (oldRole === roleThreadResult.roleColumnValue) { // If the old role is the same as the new one, we have nothing to update continue; } else if (Number(oldRole) > 0 && role === null) { // In the case where we're just trying to add somebody to a thread, if // they already have a role with a nonzero role then we don't need to do // anything continue; } oldPermissionsForChildren = userRoleInfo.oldPermissionsForChildren; permissionsFromParent = userRoleInfo.permissionsFromParent; hadMembershipRow = true; } const permissions = makePermissionsBlob( roleThreadResult.rolePermissions, permissionsFromParent, threadID, roleThreadResult.threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); if (permissions) { if (role === -1) { console.warn( `changeRole called for -1 role, but found non-null permissions for userID ${userID} and threadID ${threadID}`, ); } membershipRows.push({ operation: Number(roleThreadResult.roleColumnValue) > 0 && (!userRoleInfo || Number(userRoleInfo.oldRole) <= 0) ? 'join' : 'update', userID, threadID, permissions, permissionsForChildren, role: Number(roleThreadResult.roleColumnValue) >= 0 ? roleThreadResult.roleColumnValue : '0', }); } else { membershipRows.push({ operation: 'delete', userID, threadID, forceRowCreation: role === -1, }); } if (permissions && !hadMembershipRow) { - for (const currentUserID of memberIDs) { - if (userID !== currentUserID) { - const [user1, user2] = sortIDs(userID, currentUserID); - relationshipRows.push({ - user1, - user2, - status: undirectedStatus.KNOW_OF, - }); - } - } - memberIDs.add(userID); + relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { toUpdateDescendants.set(userID, permissionsForChildren); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, - relationshipRows: descendantRelationshipRows, + relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions(threadID, toUpdateDescendants); membershipRows.push(...descendantMembershipRows); - relationshipRows.push(...descendantRelationshipRows); + relationshipChangeset.addAll(descendantRelationshipChangeset); } - return { membershipRows, relationshipRows }; + return { membershipRows, relationshipChangeset }; } type RoleThreadResult = {| roleColumnValue: string, threadType: ThreadType, rolePermissions: ?ThreadRolePermissionsBlob, |}; async function changeRoleThreadQuery( threadID: string, role: string | -1 | 0 | null, ): Promise { if (role === 0) { const query = SQL`SELECT type FROM threads WHERE id = ${threadID}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: '0', threadType: assertThreadType(row.type), rolePermissions: null, }; } else if (role === -1) { const query = SQL`SELECT type FROM threads WHERE id = ${threadID}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: '-1', threadType: assertThreadType(row.type), rolePermissions: null, }; } else if (role !== null) { const query = SQL` SELECT t.type, r.permissions FROM threads t LEFT JOIN roles r ON r.id = ${role} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: role, threadType: assertThreadType(row.type), rolePermissions: row.permissions, }; } else { const query = SQL` SELECT t.type, t.default_role, r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: row.default_role.toString(), threadType: assertThreadType(row.type), rolePermissions: row.permissions, }; } } async function updateDescendantPermissions( initialParentThreadID: string, initialUsersToPermissionsFromParent: Map, ): Promise { const stack = [[initialParentThreadID, initialUsersToPermissionsFromParent]]; const membershipRows = []; - const relationshipRows = []; + const relationshipChangeset = new RelationshipChangeset(); while (stack.length > 0) { const [parentThreadID, usersToPermissionsFromParent] = stack.shift(); const query = SQL` SELECT t.id, m.user, t.type, r.permissions AS role_permissions, m.permissions, m.permissions_for_children, m.role FROM threads t LEFT JOIN memberships m ON m.thread = t.id LEFT JOIN roles r ON r.id = m.role WHERE t.parent_thread_id = ${parentThreadID} `; const [result] = await dbQuery(query); const childThreadInfos = new Map(); for (const row of result) { const threadID = row.id.toString(); if (!childThreadInfos.has(threadID)) { childThreadInfos.set(threadID, { threadType: assertThreadType(row.type), userInfos: new Map(), }); } if (!row.user) { continue; } const childThreadInfo = childThreadInfos.get(threadID); invariant(childThreadInfo, `value should exist for key ${threadID}`); const userID = row.user.toString(); childThreadInfo.userInfos.set(userID, { role: row.role.toString(), rolePermissions: row.role_permissions, permissions: row.permissions, permissionsForChildren: row.permissions_for_children, }); } for (const [threadID, childThreadInfo] of childThreadInfos) { const userInfos = childThreadInfo.userInfos; + const existingMemberIDs = [...userInfos.keys()]; + relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); const usersForNextLayer = new Map(); for (const [ userID, permissionsFromParent, ] of usersToPermissionsFromParent) { const userInfo = userInfos.get(userID); const role = userInfo && Number(userInfo.role) > 0 ? userInfo.role : '0'; const rolePermissions = userInfo ? userInfo.rolePermissions : null; const oldPermissions = userInfo ? userInfo.permissions : null; const oldPermissionsForChildren = userInfo ? userInfo.permissionsForChildren : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, childThreadInfo.threadType, ); if (_isEqual(permissions)(oldPermissions)) { // This thread and all of its children need no updates, since its // permissions are unchanged by this operation continue; } const permissionsForChildren = makePermissionsForChildrenBlob( permissions, ); if (permissions) { membershipRows.push({ operation: 'update', userID, threadID, permissions, permissionsForChildren, role, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !userInfo) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We assume whoever called us will handle // making sure the set of new members all have relationship rows with // each other. - for (const [existingMemberID] of userInfos) { - const [user1, user2] = sortIDs(existingMemberID, userID); - const status = undirectedStatus.KNOW_OF; - relationshipRows.push({ user1, user2, status }); - } + relationshipChangeset.setRelationshipsNeeded( + userID, + existingMemberIDs, + ); } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { usersForNextLayer.set(userID, permissionsForChildren); } } if (usersForNextLayer.size > 0) { stack.push([threadID, usersForNextLayer]); } } } - return { membershipRows, relationshipRows }; + return { membershipRows, relationshipChangeset }; } async function recalculateThreadPermissions( threadID: string, threadType: ThreadType, ): Promise { const selectQuery = SQL` SELECT m.user, m.role, m.permissions, m.permissions_for_children, pm.permissions_for_children AS permissions_from_parent, r.permissions AS role_permissions, 'existing' AS row_state FROM memberships m LEFT JOIN threads t ON t.id = m.thread LEFT JOIN roles r ON r.id = m.role LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user WHERE m.thread = ${threadID} UNION SELECT pm.user, 0 AS role, NULL AS permissions, NULL AS permissions_for_children, pm.permissions_for_children AS permissions_from_parent, NULL AS role_permissions, 'from_parent' AS row_state FROM threads t LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id LEFT JOIN memberships m ON m.thread = t.id AND m.user = pm.user WHERE t.id = ${threadID} AND m.thread IS NULL `; const [selectResult] = await dbQuery(selectQuery); - const relationshipRows = []; const membershipRows = []; + const relationshipChangeset = new RelationshipChangeset(); const toUpdateDescendants = new Map(); const existingMemberIDs = selectResult .filter((row) => row.user && row.row_state === 'existing') .map((row) => row.user.toString()); + relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); for (const row of selectResult) { if (!row.user) { continue; } const userID = row.user.toString(); const role = row.role >= 0 ? row.role.toString() : '0'; const oldPermissions = JSON.parse(row.permissions); const oldPermissionsForChildren = JSON.parse(row.permissions_for_children); const permissionsFromParent = JSON.parse(row.permissions_from_parent); const rolePermissions = JSON.parse(row.role_permissions); const hadMembershipRow = row.row_state === 'existing'; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); if (_isEqual(permissions)(oldPermissions)) { // This thread and all of its children need no updates, since its // permissions are unchanged by this operation continue; } const permissionsForChildren = makePermissionsForChildrenBlob(permissions); if (permissions) { membershipRows.push({ operation: 'update', userID, threadID, permissions, permissionsForChildren, role, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !hadMembershipRow) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We assume all the new members already have // relationship rows with each other, since they must all share the same // parent thread. - for (const existingMemberID of existingMemberIDs) { - const [user1, user2] = sortIDs(userID, existingMemberID); - const status = undirectedStatus.KNOW_OF; - relationshipRows.push({ user1, user2, status }); - } + relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { toUpdateDescendants.set(userID, permissionsForChildren); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, - relationshipRows: descendantRelationshipRows, + relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions(threadID, toUpdateDescendants); - membershipRows.push(...descendantMembershipRows); - relationshipRows.push(...descendantRelationshipRows); + relationshipChangeset.addAll(descendantRelationshipChangeset); } - return { membershipRows, relationshipRows }; + return { membershipRows, relationshipChangeset }; } const defaultSubscriptionString = JSON.stringify({ home: false, pushNotifs: false, }); const joinSubscriptionString = JSON.stringify({ home: true, pushNotifs: true }); async function saveMemberships(toSave: $ReadOnlyArray) { if (toSave.length === 0) { return; } const time = Date.now(); const insertRows = []; for (const rowToSave of toSave) { let subscription; if (rowToSave.subscription) { subscription = JSON.stringify(rowToSave.subscription); } else if (rowToSave.operation === 'join') { subscription = joinSubscriptionString; } else { subscription = defaultSubscriptionString; } const lastMessage = rowToSave.lastMessage ?? 0; const lastReadMessage = rowToSave.lastReadMessage ?? 0; insertRows.push([ rowToSave.userID, rowToSave.threadID, rowToSave.role, time, subscription, rowToSave.permissions ? JSON.stringify(rowToSave.permissions) : null, rowToSave.permissionsForChildren ? JSON.stringify(rowToSave.permissionsForChildren) : null, lastMessage, lastReadMessage, ]); } // Logic below will only update an existing membership row's `subscription` // column if the user is either joining or leaving the thread. That means // there's no way to use this function to update a user's subscription without // also making them join or leave the thread. The reason we do this is because // we need to specify a value for `subscription` here, as it's a non-null // column and this is an INSERT, but we don't want to require people to have // to know the current `subscription` when they're just using this function to // update the permissions of an existing membership row. const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${insertRows} ON DUPLICATE KEY UPDATE subscription = IF( (role <= 0 AND VALUES(role) > 0) OR (role > 0 AND VALUES(role) <= 0), VALUES(subscription), subscription ), role = VALUES(role), permissions = VALUES(permissions), permissions_for_children = VALUES(permissions_for_children) `; await dbQuery(query); } async function deleteMemberships( toDelete: $ReadOnlyArray, ) { if (toDelete.length === 0) { return; } const time = Date.now(); const insertRows = []; const deleteRows = []; for (const rowToDelete of toDelete) { if (rowToDelete.forceRowCreation) { insertRows.push([ rowToDelete.userID, rowToDelete.threadID, -1, time, defaultSubscriptionString, null, null, 0, 0, ]); } else { deleteRows.push( SQL`(user = ${rowToDelete.userID} AND thread = ${rowToDelete.threadID})`, ); } } const queries = []; if (insertRows.length > 0) { const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${insertRows} ON DUPLICATE KEY UPDATE role = -1, permissions = NULL, permissions_for_children = NULL, subscription = ${defaultSubscriptionString}, last_message = 0, last_read_message = 0 `; queries.push(dbQuery(query)); } if (deleteRows.length > 0) { const conditions = mergeOrConditions(deleteRows); const query = SQL` UPDATE memberships SET role = -1, permissions = NULL, permissions_for_children = NULL, subscription = ${defaultSubscriptionString}, last_message = 0, last_read_message = 0 WHERE `; query.append(conditions); queries.push(dbQuery(query)); } await Promise.all(queries); } // Specify non-empty changedThreadIDs to force updates to be generated for those // threads, presumably for reasons not covered in the changeset. calendarQuery // only needs to be specified if a JOIN_THREAD update will be generated for the // viewer, in which case it's necessary for knowing the set of entries to fetch. type ChangesetCommitResult = {| ...FetchThreadInfosResult, viewerUpdates: $ReadOnlyArray, userInfos: { [id: string]: AccountUserInfo }, |}; async function commitMembershipChangeset( viewer: Viewer, changeset: Changeset, { changedThreadIDs = new Set(), calendarQuery, updatesForCurrentSession = 'return', }: {| changedThreadIDs?: Set, calendarQuery?: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, |} = {}, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } - const { membershipRows, relationshipRows } = changeset; + const { membershipRows, relationshipChangeset } = changeset; const membershipRowMap = new Map(); for (const row of membershipRows) { const { userID, threadID } = row; changedThreadIDs.add(threadID); const pairString = `${userID}|${threadID}`; const existing = membershipRowMap.get(pairString); if ( !existing || (existing.operation !== 'join' && (row.operation === 'join' || (row.operation === 'delete' && existing.operation === 'update'))) ) { membershipRowMap.set(pairString, row); } } const toSave = [], toDelete = [], rescindPromises = []; for (const row of membershipRowMap.values()) { if ( row.operation === 'delete' || (row.operation === 'update' && Number(row.role) <= 0) ) { const { userID, threadID } = row; rescindPromises.push( rescindPushNotifs( SQL`n.thread = ${threadID} AND n.user = ${userID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ), ); } if (row.operation === 'delete') { toDelete.push(row); } else { toSave.push(row); } } - const uniqueRelationshipRows = _uniqWith(_isEqual)(relationshipRows); + + const threadsToSavedUsers = new Map(); + for (const row of membershipRowMap.values()) { + const { userID, threadID } = row; + let savedUsers = threadsToSavedUsers.get(threadID); + if (!savedUsers) { + savedUsers = []; + threadsToSavedUsers.set(threadID, savedUsers); + } + savedUsers.push(userID); + } + for (const savedUsers of threadsToSavedUsers.values()) { + relationshipChangeset.setAllRelationshipsNeeded(savedUsers); + } + const relationshipRows = relationshipChangeset.getRows(); + await Promise.all([ saveMemberships(toSave), deleteMemberships(toDelete), - updateUndirectedRelationships(uniqueRelationshipRows), + updateUndirectedRelationships(relationshipRows), ...rescindPromises, ]); // We fetch all threads here because old clients still expect the full list of // threads on most thread operations. Once verifyClientSupported gates on // codeVersion 62, we can add a WHERE clause on changedThreadIDs here const serverThreadInfoFetchResult = await fetchServerThreadInfos(); const { threadInfos: serverThreadInfos } = serverThreadInfoFetchResult; const time = Date.now(); const updateDatas = updateDatasForUserPairs( - uniqueRelationshipRows.map(({ user1, user2 }) => [user1, user2]), + relationshipRows.map(({ user1, user2 }) => [user1, user2]), ); for (const changedThreadID of changedThreadIDs) { const serverThreadInfo = serverThreadInfos[changedThreadID]; for (const memberInfo of serverThreadInfo.members) { const pairString = `${memberInfo.id}|${serverThreadInfo.id}`; const membershipRow = membershipRowMap.get(pairString); if (membershipRow && membershipRow.operation !== 'update') { continue; } updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: memberInfo.id, time, threadID: changedThreadID, }); } } for (const row of membershipRowMap.values()) { const { userID, threadID } = row; if (row.operation === 'join') { updateDatas.push({ type: updateTypes.JOIN_THREAD, userID, time, threadID, }); } else if (row.operation === 'delete') { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID, time, threadID, }); } } const threadInfoFetchResult = rawThreadInfosFromServerThreadInfos( viewer, serverThreadInfoFetchResult, ); const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, ...threadInfoFetchResult, updatesForCurrentSession, }); return { ...threadInfoFetchResult, userInfos, viewerUpdates, }; } function setJoinsToUnread( rows: MembershipRow[], exceptViewerID: string, exceptThreadID: string, ) { for (const row of rows) { if ( row.operation === 'join' && (row.userID !== exceptViewerID || row.threadID !== exceptThreadID) ) { row.lastMessage = 1; row.lastReadMessage = 0; } } } -function getRelationshipRowsForUsers( - viewerID: string, - userIDs: $ReadOnlyArray, -): UndirectedRelationshipRow[] { - return cartesianProduct([viewerID], userIDs).map((pair) => { - const [user1, user2] = sortIDs(...pair); - const status = undirectedStatus.KNOW_OF; - return { user1, user2, status }; - }); -} - -function getParentThreadRelationshipRowsForNewUsers( - threadID: string, - recalculateMembershipRows: MembershipRow[], - newMemberIDs: $ReadOnlyArray, -): UndirectedRelationshipRow[] { - const parentMemberIDs = recalculateMembershipRows - .map((rowToSave) => rowToSave.userID) - .filter((userID) => !newMemberIDs.includes(userID)); - const newUserIDs = newMemberIDs.filter( - (memberID) => - !recalculateMembershipRows.find( - (rowToSave) => - rowToSave.userID === memberID && - rowToSave.threadID === threadID && - rowToSave.operation !== 'delete', - ), - ); - return cartesianProduct(parentMemberIDs, newUserIDs).map((pair) => { - const [user1, user2] = sortIDs(...pair); - const status = undirectedStatus.KNOW_OF; - return { user1, user2, status }; - }); -} - async function recalculateAllThreadPermissions() { const getAllThreads = SQL`SELECT id, type FROM threads`; const [result] = await dbQuery(getAllThreads); // We handle each thread one-by-one to avoid a situation where a permission // calculation for a child thread, done during a call to // recalculateThreadPermissions for the parent thread, can be incorrectly // overriden by a call to recalculateThreadPermissions for the child thread. // If the changeset resulting from the parent call isn't committed before the // calculation is done for the child, the calculation done for the child can // be incorrect. const viewer = createScriptViewer(bots.squadbot.userID); for (const row of result) { const threadID = row.id.toString(); const threadType = assertThreadType(row.type); const changeset = await recalculateThreadPermissions(threadID, threadType); await commitMembershipChangeset(viewer, changeset); } } export { changeRole, recalculateThreadPermissions, saveMemberships, commitMembershipChangeset, setJoinsToUnread, - getRelationshipRowsForUsers, - getParentThreadRelationshipRowsForNewUsers, recalculateAllThreadPermissions, }; diff --git a/server/src/updaters/thread-updaters.js b/server/src/updaters/thread-updaters.js index 9b9e1b325..112e3abdd 100644 --- a/server/src/updaters/thread-updaters.js +++ b/server/src/updaters/thread-updaters.js @@ -1,753 +1,738 @@ // @flow import invariant from 'invariant'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, threadMemberHasPermission, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { Shape } from 'lib/types/core'; import { messageTypes, defaultNumberPerThread } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, threadPermissions, threadTypes, } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { firstLine } from 'lib/utils/string-utils'; import createMessages from '../creators/message-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos, fetchServerThreadInfos, } from '../fetchers/thread-fetchers'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, } from '../fetchers/thread-permission-fetchers'; import { verifyUserIDs, verifyUserOrCookieIDs, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; +import RelationshipChangeset from '../utils/relationship-changeset'; import { updateRoles } from './role-updaters'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, setJoinsToUnread, - getParentThreadRelationshipRowsForNewUsers, } from './thread-permission-updaters'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (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); if (!changeset) { throw new ServerError('unknown_error'); } 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)) { 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); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [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) || !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); if (!changeset) { throw new ServerError('unknown_error'); } 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)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{| +forceAddMembers: boolean, +forceUpdateRoot: 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 validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); 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 threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !viewer.isScriptViewer && (threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE || threadType === threadTypes.SIDEBAR) ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...request.changes.newMemberIDs] : null; if (Object.keys(sqlUpdate).length === 0 && !newMemberIDs) { throw new ServerError('invalid_parameters'); } if (newMemberIDs) { validationPromises.fetchNewMembers = fetchKnownUserInfos( viewer, newMemberIDs, ); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); const checks = []; if ( sqlUpdate.name !== undefined || sqlUpdate.description !== undefined || sqlUpdate.color !== undefined ) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } validationPromises.hasNecessaryPermissions = checkThread( viewer, request.threadID, checks, ); const { fetchNewMembers, serverThreadInfos, hasNecessaryPermissions, } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for CHAT_SECRET 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 === threadTypes.CHAT_SECRET && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // You can't change the parent thread of a SIDEBAR if (nextThreadType === threadTypes.SIDEBAR && parentThreadID !== undefined) { throw new ServerError('invalid_parameters'); } // 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'); } if ( nextParentThreadID && (nextParentThreadID !== oldParentThreadID || (nextThreadType === threadTypes.SIDEBAR) !== (oldThreadType === threadTypes.SIDEBAR)) ) { const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBTHREADS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } } if (fetchNewMembers) { invariant(newMemberIDs, 'should be set'); const newIDs = newMemberIDs; // for Flow for (const newMemberID of newIDs) { if (!fetchNewMembers[newMemberID]) { if (!forceAddMembers) { throw new ServerError('invalid_credentials'); } else if (nextThreadType === threadTypes.SIDEBAR) { throw new ServerError('invalid_thread_type'); } else { continue; } } const { relationshipStatus } = fetchNewMembers[newMemberID]; if ( relationshipStatus === userRelationshipStatus.FRIEND && nextThreadType !== threadTypes.SIDEBAR ) { continue; } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ) { throw new ServerError('invalid_credentials'); } else if ( threadMemberHasPermission( serverThreadInfo, newMemberID, threadPermissions.KNOW_OF, ) ) { continue; } else if (forceAddMembers && nextThreadType !== threadTypes.SIDEBAR) { continue; } throw new ServerError('invalid_credentials'); } } const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; if (Object.keys(sqlUpdate).length > 0) { const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; intermediatePromises.updateQuery = dbQuery(updateQuery); } if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await updateRolesPromise; return await changeRole(request.threadID, newMemberIDs, null); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await updateRolesPromise; return await recalculateThreadPermissions( request.threadID, nextThreadType, ); })(); } const { addMembersChangeset, recalculatePermissionsChangeset, } = await promiseAll(intermediatePromises); const membershipRows = []; - const relationshipRows = []; - if (recalculatePermissionsChangeset && newMemberIDs) { + const relationshipChangeset = new RelationshipChangeset(); + if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, - relationshipRows: recalculateRelationshipRows, + relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); - const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( - request.threadID, - recalculateMembershipRows, - newMemberIDs, - ); - relationshipRows.push( - ...recalculateRelationshipRows, - ...parentRelationshipRows, - ); - } else if (recalculatePermissionsChangeset) { - const { - membershipRows: recalculateMembershipRows, - relationshipRows: recalculateRelationshipRows, - } = recalculatePermissionsChangeset; - membershipRows.push(...recalculateMembershipRows); - relationshipRows.push(...recalculateRelationshipRows); + relationshipChangeset.addAll(recalculateRelationshipChangeset); } if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, - relationshipRows: addMembersRelationshipRows, + relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; - relationshipRows.push(...addMembersRelationshipRows); setJoinsToUnread(addMembersMembershipRows, viewer.userID, request.threadID); membershipRows.push(...addMembersMembershipRows); + relationshipChangeset.addAll(addMembersRelationshipChangeset); } - const changeset = { membershipRows, relationshipRows }; + 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(), }, ); 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 (newMemberIDs) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: newMemberIDs, }); } const newMessageInfos = await createMessages(viewer, messageDatas); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ), ]); if (isMember || !hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); if (!changeset) { throw new ServerError('unknown_error'); } setJoinsToUnread(changeset.membershipRows, viewer.userID, request.threadID); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; await createMessages(viewer, [messageData]); const threadSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, threadSelectionCriteria, 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 (!hasMinCodeVersion(viewer.platformDetails, 62)) { 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); } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, }; diff --git a/server/src/utils/relationship-changeset.js b/server/src/utils/relationship-changeset.js new file mode 100644 index 000000000..e37751181 --- /dev/null +++ b/server/src/utils/relationship-changeset.js @@ -0,0 +1,100 @@ +// @flow + +import invariant from 'invariant'; + +import { sortIDs } from 'lib/shared/relationship-utils'; +import { + type UndirectedRelationshipRow, + undirectedStatus, +} from 'lib/types/relationship-types'; + +type RelationshipStatus = 'existing' | 'potentially_missing'; + +class RelationshipChangeset { + relationships: Map = new Map(); + finalized = false; + + static _getKey(userA: string, userB: string): string { + const [user1, user2] = sortIDs(userA, userB); + return `${user1}|${user2}`; + } + + _setRelationshipForKey(key: string, status: RelationshipStatus) { + invariant( + !this.finalized, + 'attempting to set relationship on finalized RelationshipChangeset', + ); + const currentStatus = this.relationships.get(key); + if ( + currentStatus === 'existing' || + (currentStatus && status === 'potentially_missing') + ) { + return; + } + this.relationships.set(key, status); + } + + _setRelationship(userA: string, userB: string, status: RelationshipStatus) { + if (userA === userB) { + return; + } + const key = RelationshipChangeset._getKey(userA, userB); + this._setRelationshipForKey(key, status); + } + + setAllRelationshipsExist(userIDs: $ReadOnlyArray) { + for (let i = 0; i < userIDs.length; i++) { + for (let j = i + 1; j < userIDs.length; j++) { + this._setRelationship(userIDs[i], userIDs[j], 'existing'); + } + } + } + + setAllRelationshipsNeeded(userIDs: $ReadOnlyArray) { + for (let i = 0; i < userIDs.length; i++) { + for (let j = i + 1; j < userIDs.length; j++) { + this._setRelationship(userIDs[i], userIDs[j], 'potentially_missing'); + } + } + } + + setRelationshipExists(userA: string, userB: string) { + this._setRelationship(userA, userB, 'existing'); + } + + setRelationshipsNeeded(userID: string, otherUserIDs: $ReadOnlyArray) { + for (const otherUserID of otherUserIDs) { + this._setRelationship(userID, otherUserID, 'potentially_missing'); + } + } + + addAll(other: RelationshipChangeset) { + other.finalized = true; + for (const [key, status] of other.relationships) { + this._setRelationshipForKey(key, status); + } + } + + _getRows(): UndirectedRelationshipRow[] { + const rows = []; + for (const [key, status] of this.relationships) { + if (status === 'existing') { + continue; + } + const [user1, user2] = key.split('|'); + rows.push({ user1, user2, status: undirectedStatus.KNOW_OF }); + } + return rows; + } + + getRows(): UndirectedRelationshipRow[] { + this.finalized = true; + return this._getRows(); + } + + getRowCount(): number { + return this._getRows().length; + } +} + +export default RelationshipChangeset;