diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index efa1a883f..1f9d47ee3 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,512 +1,505 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots'; import genesis from 'lib/facts/genesis'; 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 { type ServerNewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, threadTypeIsCommunityRoot, } from 'lib/types/thread-types'; import type { UserInfos } from 'lib/types/user-types'; import { pushAll } from 'lib/utils/array'; 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 { determineThreadAncestry } from '../fetchers/thread-fetchers'; +import { + determineThreadAncestry, + personalThreadQuery, +} from '../fetchers/thread-fetchers'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers'; import type { Viewer } from '../session/viewer'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, } from '../updaters/thread-permission-updaters'; import { joinThread } from '../updaters/thread-updaters'; import RelationshipChangeset from '../utils/relationship-changeset'; import createIDs from './id-creator'; import createMessages from './message-creator'; import { createInitialRolesForNewThread, getRolePermissionBlobs, } from './role-creator'; import type { UpdatesForCurrentSession } from './update-creator'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Shape<{ +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, +silentlyFailMembers: boolean, }>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: ServerNewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; let parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? [...new Set(request.initialMemberIDs)] : null; const ghostMemberIDsFromRequest = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? [...new Set(request.ghostMemberIDs)] : null; const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const parentRequirement = getThreadTypeParentRequirement(threadType); if ( (parentRequirement === 'required' && !parentThreadID) || (parentRequirement === 'disabled' && parentThreadID) ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && request.initialMemberIDs?.length !== 1 ) { throw new ServerError('invalid_parameters'); } const requestParentThreadID = parentThreadID; const confirmParentPermissionPromise = (async () => { if (!requestParentThreadID) { return; } const hasParentPermission = await checkThreadPermission( viewer, requestParentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } })(); // This is a temporary hack until we release actual E2E-encrypted local // conversations. For now we are hosting all root threads on Ashoat's // keyserver, so we set them to the have the Genesis community as their // parent thread. if (!parentThreadID && !threadTypeIsCommunityRoot(threadType)) { parentThreadID = genesis.id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, containingThreadID: threadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !shouldCreateRelationships }, ); if ( !silentlyFailMembers && (Number(initialMemberIDs?.length) < Number(initialMemberIDsFromRequest?.length) || Number(ghostMemberIDs?.length) < Number(ghostMemberIDsFromRequest?.length)) ) { throw new ServerError('invalid_credentials'); } return { initialMemberIDs, ghostMemberIDs }; })(); const checkPromises = {}; checkPromises.confirmParentPermission = confirmParentPermissionPromise; checkPromises.threadAncestry = determineThreadAncestryPromise; checkPromises.validateMembers = validateMembersPromise; if (sourceMessageID) { checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); } const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll(checkPromises); let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor([ ...(request.initialMemberIDs ?? []), viewer.id, ]); } const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, threadAncestry.containingThreadID, threadAncestry.community, threadAncestry.depth, newRoles.default.id, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); - existingThreadQuery = 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 > 0 - AND m2.role > 0 - `; + existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) SELECT ${row} WHERE NOT EXISTS (`; query.append(existingThreadQuery).append(SQL`)`); const [result] = await dbQuery(query); if (result.affectedRows === 0) { const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[existingThreadResult]] = await Promise.all([ dbQuery(existingThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant(existingThreadResult.length > 0, 'thread should exist'); const existingThreadID = existingThreadResult[0].id.toString(); let calendarQuery; if (hasMinCodeVersion(viewer.platformDetails, 87)) { invariant(request.calendarQuery, 'calendar query should exist'); calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; } let joinUpdateInfos = []; let userInfos: UserInfos = {}; let newMessageInfos = []; if (threadType !== threadTypes.PERSONAL) { const joinThreadResult = await joinThread(viewer, { threadID: existingThreadID, calendarQuery, }); joinUpdateInfos = joinThreadResult.updatesResult.newUpdates; userInfos = joinThreadResult.userInfos; newMessageInfos = joinThreadResult.rawMessageInfos; } const { viewerUpdates: newUpdates, userInfos: changesetUserInfos, } = await getChangesetCommitResultForExistingThread( viewer, existingThreadID, joinUpdateInfos, { calendarQuery, updatesForCurrentSession }, ); userInfos = { ...userInfos, ...changesetUserInfos }; return { newThreadID: existingThreadID, updatesResult: { newUpdates, }, userInfos, newMessageInfos, }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } let initialMemberPromise; if (initialMemberIDs) { initialMemberPromise = changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true, }); } let ghostMemberPromise; if (ghostMemberIDs) { ghostMemberPromise = changeRole(id, ghostMemberIDs, -1); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberPromise, ghostMemberPromise, recalculateThreadPermissions(id), ]); const { membershipRows: creatorMembershipRows, relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipChangeset = new RelationshipChangeset(); relationshipChangeset.addAll(creatorRelationshipChangeset); relationshipChangeset.addAll(recalculateRelationshipChangeset); if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipChangeset: initialMembersRelationshipChangeset, } = initialMembersChangeset; pushAll(membershipRows, initialMembersMembershipRows); relationshipChangeset.addAll(initialMembersRelationshipChangeset); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipChangeset: ghostMembersRelationshipChangeset, } = ghostMembersChangeset; pushAll(membershipRows, ghostMembersMembershipRows); relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates, userInfos, } = await commitMembershipChangeset(viewer, changeset, { updatesForCurrentSession, }); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); if (!sourceMessage || sourceMessage.type === messageTypes.SIDEBAR_SOURCE) { throw new ServerError('invalid_parameters'); } 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 && (parentThreadID !== genesis.id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread( viewer: Viewer, username: string, ): Promise { return createThread( viewer, { type: threadTypes.PRIVATE, name: username, description: privateThreadDescription, ghostMemberIDs: [commbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread, privateThreadDescription }; diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js index 0ab8e0509..204c9694d 100644 --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -1,224 +1,252 @@ // @flow import { getAllThreadPermissions } from 'lib/permissions/thread-permissions'; import { rawThreadInfoFromServerThreadInfo, getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { threadTypes, type ThreadType, type RawThreadInfo, type ServerThreadInfo, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL } from '../database/database'; import type { SQLStatementType } from '../database/types'; import type { Viewer } from '../session/viewer'; type FetchServerThreadInfosResult = { +threadInfos: { +[id: string]: ServerThreadInfo }, }; async function fetchServerThreadInfos( condition?: SQLStatementType, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const query = SQL` SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, t.community, t.depth, t.color, t.description, t.type, t.creation_time, t.default_role, t.source_message, t.replies_count, r.id AS role, r.name AS role_name, r.permissions AS role_permissions, m.user, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender FROM threads t LEFT JOIN ( SELECT thread, id, name, permissions FROM roles UNION SELECT id AS thread, 0 AS id, NULL AS name, NULL AS permissions FROM threads ) r ON r.thread = t.id LEFT JOIN memberships m ON m.role = r.id AND m.thread = t.id AND m.role >= 0 ` .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [result] = await dbQuery(query); const threadInfos = {}; for (const row of result) { const threadID = row.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: row.type, name: row.name ? row.name : '', description: row.description ? row.description : '', color: row.color, creationTime: row.creation_time, parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, containingThreadID: row.containing_thread_id ? row.containing_thread_id.toString() : null, depth: row.depth, community: row.community ? row.community.toString() : null, members: [], roles: {}, repliesCount: row.replies_count, }; } const sourceMessageID = row.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } const role = row.role.toString(); if (row.role && !threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: row.role_name, permissions: JSON.parse(row.role_permissions), isDefault: role === row.default_role.toString(), }; } if (row.user) { const userID = row.user.toString(); const allPermissions = getAllThreadPermissions( JSON.parse(row.permissions), threadID, ); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: row.role ? role : null, subscription: JSON.parse(row.subscription), unread: row.role ? !!row.unread : null, isSender: !!row.sender, }); } } return { threadInfos }; } export type FetchThreadInfosResult = { +threadInfos: { +[id: string]: RawThreadInfo }, }; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatementType, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } const shimCommunityRoot = { [threadTypes.COMMUNITY_ROOT]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD]: threadTypes.COMMUNITY_OPEN_SUBTHREAD, [threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.GENESIS]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, }; function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const hasCodeVersionBelow70 = !hasMinCodeVersion(viewer.platformDetails, 70); const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); const hasCodeVersionBelow102 = !hasMinCodeVersion( viewer.platformDetails, 102, ); const hasCodeVersionBelow104 = !hasMinCodeVersion( viewer.platformDetails, 104, ); const threadInfos = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { includeVisibilityRules: hasCodeVersionBelow70, filterMemberList: hasCodeVersionBelow70, shimThreadTypes: hasCodeVersionBelow87 ? shimCommunityRoot : null, hideThreadStructure: hasCodeVersionBelow102, filterDetailedThreadEditPermissions: hasCodeVersionBelow104, }, ); if (threadInfo) { threadInfos[threadID] = threadInfo; } } return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; for (const row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } type ThreadAncestry = { +containingThreadID: ?string, +community: ?string, +depth: number, }; async function determineThreadAncestry( parentThreadID: ?string, threadType: ThreadType, ): Promise { if (!parentThreadID) { return { containingThreadID: null, community: null, depth: 0 }; } const parentThreadInfos = await fetchServerThreadInfos( SQL`t.id = ${parentThreadID}`, ); const parentThreadInfo = parentThreadInfos.threadInfos[parentThreadID]; if (!parentThreadInfo) { throw new ServerError('invalid_parameters'); } const containingThreadID = getContainingThreadID( parentThreadInfo, threadType, ); const community = getCommunity(parentThreadInfo); const depth = parentThreadInfo.depth + 1; return { containingThreadID, community, depth }; } +function personalThreadQuery( + firstMemberID: string, + secondMemberID: string, +): SQLStatementType { + return SQL` + SELECT t.id + FROM threads t + INNER JOIN memberships m1 + ON m1.thread = t.id AND m1.user = ${firstMemberID} + INNER JOIN memberships m2 + ON m2.thread = t.id AND m2.user = ${secondMemberID} + WHERE t.type = ${threadTypes.PERSONAL} + AND m1.role > 0 + AND m2.role > 0 + `; +} + +async function fetchPersonalThreadID( + viewerID: string, + otherMemberID: string, +): Promise { + const query = personalThreadQuery(viewerID, otherMemberID); + const [threads] = await dbQuery(query); + return threads[0]?.id.toString(); +} + export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, determineThreadAncestry, + personalThreadQuery, + fetchPersonalThreadID, };