diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index 1d0daa77f..f29c9cef1 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,509 +1,510 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { generatePendingThreadColor, generateRandomColor, } 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, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, } from '../updaters/thread-permission-updaters'; 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', ); if ( threadType !== threadTypes.CHAT_SECRET && threadType !== threadTypes.PERSONAL && threadType !== threadTypes.PRIVATE && !parentThreadID ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && (request.initialMemberIDs?.length !== 1 || parentThreadID) ) { 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 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'); } 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 ? request.name : null; + 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, ]; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); 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 ( SELECT * 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 ) `; const [result] = await dbQuery(query); if (result.affectedRows === 0) { const personalThreadQuery = 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 `; 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 [[personalThreadResult]] = await Promise.all([ dbQuery(personalThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant( personalThreadResult.length > 0, 'PERSONAL thread should exist', ); const personalThreadID = personalThreadResult[0].id.toString(); return { newThreadID: personalThreadID, 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, recalculateAllPermissions(id, threadType), ]); if (!creatorChangeset) { throw new ServerError('unknown_error'); } const { membershipRows: creatorMembershipRows, relationshipRows: creatorRelationshipRows, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = 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, ); } setJoinsToUnread(membershipRows, viewer.userID, id); const changeset = { membershipRows, relationshipRows }; 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/updaters/thread-updaters.js b/server/src/updaters/thread-updaters.js index f12d0f0f2..bca36a8e4 100644 --- a/server/src/updaters/thread-updaters.js +++ b/server/src/updaters/thread-updaters.js @@ -1,721 +1,723 @@ // @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 { updateRoles } from './role-updaters'; import { changeRole, recalculateAllPermissions, 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 (let 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 (let 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 (let 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 { name } = request.changes; - if (name !== undefined && name !== null) { + 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) ) { 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'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; 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 threadRootChanged = forceUpdateRoot || nextParentThreadID !== oldParentThreadID || nextThreadType !== oldThreadType; if (threadRootChanged && nextParentThreadID) { 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 intermediatePromises = {}; if (Object.keys(sqlUpdate).length > 0) { const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; intermediatePromises.updateQuery = dbQuery(updateQuery); } if (newMemberIDs) { intermediatePromises.addMembersChangeset = changeRole( request.threadID, newMemberIDs, null, ); } if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { if (forceUpdateRoot || nextThreadType !== oldThreadType) { await updateRoles(viewer, request.threadID, nextThreadType); } return await recalculateAllPermissions(request.threadID, nextThreadType); })(); } const { addMembersChangeset, recalculatePermissionsChangeset, } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipRows = []; if (recalculatePermissionsChangeset && newMemberIDs) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( request.threadID, recalculateMembershipRows, newMemberIDs, ); relationshipRows.push( ...recalculateRelationshipRows, ...parentRelationshipRows, ); } else if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipRows.push(...recalculateRelationshipRows); } if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipRows: addMembersRelationshipRows, } = addMembersChangeset; relationshipRows.push(...addMembersRelationshipRows); setJoinsToUnread(addMembersMembershipRows, viewer.userID, request.threadID); membershipRows.push(...addMembersMembershipRows); } const changeset = { membershipRows, relationshipRows }; 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 (let fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (newMemberIDs) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: newMemberIDs, }); } const 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, };