diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index 105edc8bc..0f5d7d9fd 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,528 +1,535 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/color-utils.js'; import { isInvalidSidebarSource } from 'lib/shared/message-utils.js'; import { getThreadTypeParentRequirement } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawMessageInfo, MessageData } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, } from 'lib/types/thread-types-enum.js'; import { type ServerNewThinThreadRequest, type NewThreadResponse, } from 'lib/types/thread-types.js'; import type { ServerUpdateInfo } from 'lib/types/update-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createInitialRolesForNewThread } from './role-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchLatestEditMessageContentByID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { determineThreadAncestry, personalThreadQuery, + determineThreadAncestryForPossibleMemberResolution, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, type MembershipChangeset, } from '../updaters/thread-permission-updaters.js'; import { joinThread } from '../updaters/thread-updaters.js'; import { isAuthoritativeKeyserver } from '../user/identity.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Partial<{ +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: ServerNewThinThreadRequest, 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.GENESIS_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.GENESIS_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)) { const isAuthoritative = await isAuthoritativeKeyserver(); if (!isAuthoritative) { throw new ServerError('invalid_parameters'); } parentThreadID = genesis().id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; + const containingThreadIDForPossibleMemberResolution = + determineThreadAncestryForPossibleMemberResolution( + parentThreadID, + threadAncestry.containingThreadID, + ); const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, - containingThreadID: threadAncestry.containingThreadID, + containingThreadID: containingThreadIDForPossibleMemberResolution, defaultRolePermissions, + communityID: threadAncestry.community, }, { 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 sourceMessagePromise: Promise = sourceMessageID ? fetchMessageInfoByID(viewer, sourceMessageID) : Promise.resolve(undefined); const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll({ sourceMessage: sourceMessagePromise, threadAncestry: determineThreadAncestryPromise, validateMembers: validateMembersPromise, confirmParentPermission: confirmParentPermissionPromise, }); if (sourceMessage && isInvalidSidebarSource(sourceMessage)) { throw new ServerError('invalid_parameters'); } let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.GENESIS_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, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.GENESIS_PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a GENESIS_PERSONAL thread', ); existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, 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(); invariant(request.calendarQuery, 'calendar query should exist'); const calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; let joinUpdateInfos: $ReadOnlyArray = []; let userInfos: UserInfos = {}; let newMessageInfos: $ReadOnlyArray = []; if (threadType !== threadTypes.GENESIS_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` START TRANSACTION; INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, source_message) VALUES ${[row]}; `; if (threadTypeIsCommunityRoot(threadType)) { query.append(SQL` INSERT INTO communities (id) VALUES (${id}); `); } query.append(SQL` COMMIT; `); await dbQuery(query, { multipleStatements: true }); } const initialMemberPromise: Promise = initialMemberIDs ? changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true }) : Promise.resolve(undefined); const ghostMemberPromise: Promise = ghostMemberIDs ? changeRole(id, ghostMemberIDs, -1) : Promise.resolve(undefined); 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 { viewerUpdates, userInfos } = await commitMembershipChangeset( viewer, changeset, { updatesForCurrentSession, }, ); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas: Array = []; 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) { throw new ServerError('invalid_parameters'); } invariant( sourceMessage.type !== messageTypes.REACTION && sourceMessage.type !== messageTypes.EDIT_MESSAGE && sourceMessage.type !== messageTypes.SIDEBAR_SOURCE && sourceMessage.type !== messageTypes.TOGGLE_PIN, 'Invalid sidebar source type', ); let editedSourceMessage = sourceMessage; if (sourceMessageID && sourceMessage.type === messageTypes.TEXT) { const editMessageContent = await fetchLatestEditMessageContentByID(sourceMessageID); if (editMessageContent) { editedSourceMessage = { ...sourceMessage, text: editMessageContent.text, }; } } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage: editedSourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if ( parentThreadID && threadType !== threadTypes.SIDEBAR && (parentThreadID !== genesis().id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread(viewer: Viewer): Promise { return createThread( viewer, { type: threadTypes.GENESIS_PRIVATE, 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 d52790362..053bb1c29 100644 --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -1,449 +1,463 @@ // @flow import invariant from 'invariant'; +import genesis from 'lib/facts/genesis.js'; import { specialRoles } from 'lib/permissions/special-roles.js'; import { getAllThreadPermissions } from 'lib/permissions/thread-permissions.js'; import { rawThreadInfoFromServerThreadInfo, getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { AvatarDBContent, ClientAvatar } from 'lib/types/avatar-types.js'; import type { RawMessageInfo, MessageInfo } from 'lib/types/message-types.js'; import type { ThinRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes, type ThreadType } from 'lib/types/thread-types-enum.js'; import { type ServerThreadInfo, type ServerLegacyRoleInfo, type LegacyThinRawThreadInfo, } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { getUploadURL, makeUploadURI } from './upload-fetchers.js'; import { dbQuery, SQL, mergeAndConditions } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { Viewer } from '../session/viewer.js'; type FetchThreadInfosFilter = Partial<{ +accessibleToUserID: string, +threadID: string, +threadIDs: $ReadOnlySet, +parentThreadID: string, +sourceMessageID: string, }>; function constructWhereClause( filter: FetchThreadInfosFilter, ): SQLStatementType { const fromTable = filter.accessibleToUserID ? 'memberships' : 'threads'; const conditions = []; if (filter.accessibleToUserID) { conditions.push( SQL`mm.user = ${filter.accessibleToUserID} AND mm.role > -1`, ); } if (filter.threadID && fromTable === 'memberships') { conditions.push(SQL`mm.thread = ${filter.threadID}`); } else if (filter.threadID) { conditions.push(SQL`t.id = ${filter.threadID}`); } if (filter.threadIDs && fromTable === 'memberships') { conditions.push(SQL`mm.thread IN (${[...filter.threadIDs]})`); } else if (filter.threadIDs) { conditions.push(SQL`t.id IN (${[...filter.threadIDs]})`); } if (filter.parentThreadID) { conditions.push(SQL`t.parent_thread_id = ${filter.parentThreadID}`); } if (filter.sourceMessageID) { conditions.push(SQL`t.source_message = ${filter.sourceMessageID}`); } if (conditions.length === 0) { return SQL``; } const clause = mergeAndConditions(conditions); return SQL`WHERE `.append(clause); } type FetchServerThreadInfosResult = { +threadInfos: { +[id: string]: ServerThreadInfo }, }; async function fetchServerThreadInfos( filter?: FetchThreadInfosFilter, ): Promise { if (filter?.threadIDs?.size === 0) { return { threadInfos: {} }; } let primaryFetchClause; if (filter?.accessibleToUserID) { primaryFetchClause = SQL` FROM memberships mm LEFT JOIN threads t ON t.id = mm.thread `; } else { primaryFetchClause = SQL` FROM threads t `; } const whereClause = filter ? constructWhereClause(filter) : ''; const rolesQuery = SQL` SELECT t.id, r.id AS role, r.name, r.permissions, r.special_role, r.special_role = ${specialRoles.DEFAULT_ROLE} AS is_default ` .append(primaryFetchClause) .append( SQL` LEFT JOIN roles r ON r.thread = t.id `, ) .append(whereClause); const threadsQuery = SQL` SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, t.community, t.depth, t.color, t.description, t.type, t.creation_time, t.source_message, t.replies_count, t.avatar, t.pinned_count, m.user, m.role, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender, up.id AS upload_id, up.secret AS upload_secret, up.extra AS upload_extra ` .append(primaryFetchClause) .append( SQL` LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0 LEFT JOIN uploads up ON up.container = t.id `, ) .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [[threadsResult], [rolesResult]] = await Promise.all([ dbQuery(threadsQuery), dbQuery(rolesQuery), ]); const threadInfos = {}; for (const threadsRow of threadsResult) { const threadID = threadsRow.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: threadsRow.type, name: threadsRow.name ? threadsRow.name : '', description: threadsRow.description ? threadsRow.description : '', color: threadsRow.color, creationTime: threadsRow.creation_time, parentThreadID: threadsRow.parent_thread_id ? threadsRow.parent_thread_id.toString() : null, containingThreadID: threadsRow.containing_thread_id ? threadsRow.containing_thread_id.toString() : null, depth: threadsRow.depth, community: threadsRow.community ? threadsRow.community.toString() : null, members: [], roles: {}, repliesCount: threadsRow.replies_count, pinnedCount: threadsRow.pinned_count, }; if (threadsRow.avatar) { const avatar: AvatarDBContent = JSON.parse(threadsRow.avatar); let clientAvatar: ?ClientAvatar; if ( avatar && avatar.type !== 'image' && avatar.type !== 'encrypted_image' ) { clientAvatar = avatar; } else if ( avatar && (avatar.type === 'image' || avatar.type === 'encrypted_image') && threadsRow.upload_id && threadsRow.upload_secret ) { const uploadID = threadsRow.upload_id.toString(); invariant( uploadID === avatar.uploadID, `uploadID of upload should match uploadID of image avatar`, ); if (avatar.type === 'encrypted_image' && threadsRow.upload_extra) { const uploadExtra = JSON.parse(threadsRow.upload_extra); clientAvatar = { type: 'encrypted_image', blobURI: makeUploadURI( uploadExtra.blobHash, uploadID, threadsRow.upload_secret, ), encryptionKey: uploadExtra.encryptionKey, thumbHash: uploadExtra.thumbHash, }; } else { clientAvatar = { type: 'image', uri: getUploadURL(uploadID, threadsRow.upload_secret), }; } } threadInfos[threadID] = { ...threadInfos[threadID], avatar: clientAvatar, }; } } const sourceMessageID = threadsRow.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } if (threadsRow.user) { const userID = threadsRow.user.toString(); const allPermissions = getAllThreadPermissions( JSON.parse(threadsRow.permissions), threadID, ); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: threadsRow.role ? threadsRow.role.toString() : null, subscription: JSON.parse(threadsRow.subscription), unread: threadsRow.role ? !!threadsRow.unread : null, isSender: !!threadsRow.sender, }); } } for (const rolesRow of rolesResult) { const threadID = rolesRow.id.toString(); if (!rolesRow.role) { continue; } const role = rolesRow.role.toString(); if (!threadInfos[threadID].roles[role]) { const roleInfo: ServerLegacyRoleInfo = { id: role, name: rolesRow.name, permissions: JSON.parse(rolesRow.permissions), isDefault: Boolean(rolesRow.is_default), specialRole: rolesRow.special_role, }; threadInfos[threadID].roles[role] = roleInfo; } } return { threadInfos }; } export type FetchThreadInfosResult = { +threadInfos: { +[id: string]: LegacyThinRawThreadInfo | ThinRawThreadInfo, }, }; async function fetchThreadInfos( viewer: Viewer, inputFilter?: FetchThreadInfosFilter, ): Promise { const filter = { accessibleToUserID: viewer.id, ...inputFilter, }; const serverResult = await fetchServerThreadInfos(filter); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const codeVersionBelow209 = !hasMinCodeVersion(viewer.platformDetails, { native: 209, }); const codeVersionBelow213 = !hasMinCodeVersion(viewer.platformDetails, { native: 213, }); const codeVersionBelow221 = !hasMinCodeVersion(viewer.platformDetails, { native: 221, }); const codeVersionBelow283 = !hasMinCodeVersion(viewer.platformDetails, { native: 285, }); const minimallyEncodedPermissionsSupported = hasMinCodeVersion( viewer.platformDetails, { native: 301, web: 56 }, ); const specialRoleFieldSupported = hasMinCodeVersion(viewer.platformDetails, { native: 336, web: 79, }); const addingUsersToCommunityRootSupported = !hasMinCodeVersion( viewer.platformDetails, { native: 355, web: 88, }, ); const manageFarcasterChannelTagsPermissionUnsupported = !hasMinCodeVersion( viewer.platformDetails, { native: 355, web: 88, }, ); const threadInfos: { [string]: LegacyThinRawThreadInfo | ThinRawThreadInfo, } = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { filterThreadEditAvatarPermission: codeVersionBelow213, excludePinInfo: codeVersionBelow209, filterManageInviteLinksPermission: codeVersionBelow221, filterVoicedInAnnouncementChannelsPermission: codeVersionBelow283, minimallyEncodePermissions: minimallyEncodedPermissionsSupported, includeSpecialRoleFieldInRoles: specialRoleFieldSupported, allowAddingUsersToCommunityRoot: addingUsersToCommunityRootSupported, filterManageFarcasterChannelTagsPermission: manageFarcasterChannelTagsPermissionUnsupported, }, ); 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({ threadID: 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 determineThreadAncestryForPossibleMemberResolution( + parentThreadID: ?string, + containingThreadID: ?string, +): ?string { + let resolvedContainingThreadID = containingThreadID; + if (resolvedContainingThreadID === genesis().id) { + resolvedContainingThreadID = + parentThreadID === genesis().id ? null : parentThreadID; + } + return resolvedContainingThreadID; +} + 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.GENESIS_PERSONAL} AND m1.role > 0 AND m2.role > 0 `; } async function fetchPersonalThreadID( viewerID: string, otherMemberID: string, ): Promise { const query = personalThreadQuery(viewerID, otherMemberID); const [threads] = await dbQuery(query); return threads[0]?.id.toString(); } async function serverThreadInfoFromMessageInfo( message: RawMessageInfo | MessageInfo, ): Promise { const threadID = message.threadID; const threads = await fetchServerThreadInfos({ threadID }); return threads.threadInfos[threadID]; } async function fetchContainedThreadIDs( parentThreadID: string, ): Promise> { const query = SQL` WITH RECURSIVE thread_tree AS ( SELECT id, containing_thread_id FROM threads WHERE id = ${parentThreadID} UNION ALL SELECT t.id, t.containing_thread_id FROM threads t JOIN thread_tree tt ON t.containing_thread_id = tt.id ) SELECT id FROM thread_tree `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, determineThreadAncestry, + determineThreadAncestryForPossibleMemberResolution, personalThreadQuery, fetchPersonalThreadID, serverThreadInfoFromMessageInfo, fetchContainedThreadIDs, }; diff --git a/keyserver/src/fetchers/thread-permission-fetchers.js b/keyserver/src/fetchers/thread-permission-fetchers.js index 7d26676b5..71df9ba93 100644 --- a/keyserver/src/fetchers/thread-permission-fetchers.js +++ b/keyserver/src/fetchers/thread-permission-fetchers.js @@ -1,437 +1,441 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import genesis from 'lib/facts/genesis.js'; import { permissionLookup, makePermissionsBlob, getRoleForPermissions, } from 'lib/permissions/thread-permissions.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import { permissionsDisabledByBlock, threadIsWithBlockedUserOnlyWithoutAdminRoleCheck, threadMembersWithoutAddedAdmin, roleIsAdminRole, } from 'lib/shared/thread-utils.js'; import type { MemberInfoWithPermissions, RelativeMemberInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import type { ThreadPermission, ThreadPermissionsBlob, ThreadRolePermissionsBlob, } from 'lib/types/thread-permission-types.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; import { values } from 'lib/utils/objects.js'; import { fetchThreadInfos } from './thread-fetchers.js'; import { fetchKnownUserInfos } from './user-fetchers.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; // Note that it's risky to verify permissions by inspecting the blob directly. // There are other factors that can override permissions in the permissions // blob, such as when one user blocks another. It's always better to go through // checkThreads and friends, or by looking at the ThreadInfo through // threadHasPermission. async function fetchThreadPermissionsBlob( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT permissions FROM memberships WHERE thread = ${threadID} AND user = ${viewerID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return JSON.parse(row.permissions); } function checkThreadPermission( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { return checkThread(viewer, threadID, [{ check: 'permission', permission }]); } function viewerIsMember(viewer: Viewer, threadID: string): Promise { return checkThread(viewer, threadID, [{ check: 'is_member' }]); } type Check = | { +check: 'is_member' } | { +check: 'permission', +permission: ThreadPermission }; function isThreadValid( permissions: ?ThreadPermissionsBlob, role: number, checks: $ReadOnlyArray, ): boolean { for (const check of checks) { if (check.check === 'is_member') { if (role <= 0) { return false; } } else if (check.check === 'permission') { if (!permissionLookup(permissions, check.permission)) { return false; } } } return true; } async function checkThreads( viewer: Viewer, threadIDs: $ReadOnlyArray, checks: $ReadOnlyArray, ): Promise> { if (viewer.isScriptViewer) { // script viewers are all-powerful return new Set(threadIDs); } const threadRows = await getValidThreads(viewer, threadIDs, checks); return new Set(threadRows.map(row => row.threadID)); } type PartialMembershipRow = { +threadID: string, +role: number, +permissions: ThreadPermissionsBlob, }; async function getValidThreads( viewer: Viewer, threadIDs: $ReadOnlyArray, checks: $ReadOnlyArray, ): Promise { const query = SQL` SELECT thread AS threadID, permissions, role FROM memberships WHERE thread IN (${threadIDs}) AND user = ${viewer.userID} `; const permissionsToCheck = []; for (const check of checks) { if (check.check === 'permission') { permissionsToCheck.push(check.permission); } } const [[result], disabledThreadIDs] = await Promise.all([ dbQuery(query), checkThreadsFrozen(viewer, permissionsToCheck, threadIDs), ]); return result .map(row => ({ ...row, threadID: row.threadID.toString(), permissions: JSON.parse(row.permissions), })) .filter( row => isThreadValid(row.permissions, row.role, checks) && !disabledThreadIDs.has(row.threadID), ); } async function checkThreadsFrozen( viewer: Viewer, permissionsToCheck: $ReadOnlyArray, threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlySet> { const threadIDsWithDisabledPermissions = new Set(); const permissionMightBeDisabled = permissionsToCheck.some(permission => permissionsDisabledByBlock.has(permission), ); if (!permissionMightBeDisabled) { return threadIDsWithDisabledPermissions; } const [{ threadInfos }, userInfos] = await Promise.all([ fetchThreadInfos(viewer, { threadIDs: new Set(threadIDs) }), fetchKnownUserInfos(viewer), ]); const communityThreadIDs = new Set(); for (const threadInfo of values(threadInfos)) { const communityRootThreadID = threadInfo.community; if (!communityRootThreadID) { continue; } communityThreadIDs.add(communityRootThreadID); } const { threadInfos: communityThreadInfos } = await fetchThreadInfos(viewer, { threadIDs: communityThreadIDs, }); const combinedThreadInfos = { ...threadInfos, ...communityThreadInfos, }; const communityRootMembersToRole = _mapValues((threadInfo: ThreadInfo) => { const keyedMembers = _keyBy('id')(threadInfo.members); const keyedMembersToRole = _mapValues( (member: MemberInfoWithPermissions | RelativeMemberInfo) => { return member.role ? threadInfo.roles[member.role] : null; }, )(keyedMembers); return keyedMembersToRole; })(combinedThreadInfos); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threadInfo.community && !(threadInfo.community in communityRootMembersToRole) ) { threadIDsWithDisabledPermissions.add(threadID); continue; } // We fall back to threadID because the only case where threadInfo.community // is not set is for a community root, in which case the thread's community // root is the thread itself. const communityMembersToRole = communityRootMembersToRole[threadInfo.community ?? threadID]; const memberHasAdminRole = threadMembersWithoutAddedAdmin(threadInfo).some( m => roleIsAdminRole(communityMembersToRole?.[m.id]), ); if (memberHasAdminRole) { continue; } const blockedThread = threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, viewer.id, userInfos, false, ); if (blockedThread) { threadIDsWithDisabledPermissions.add(threadID); } } return threadIDsWithDisabledPermissions; } async function checkIfThreadIsBlocked( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { const disabledThreadIDs = await checkThreadsFrozen( viewer, [permission], [threadID], ); return disabledThreadIDs.has(threadID); } async function checkThread( viewer: Viewer, threadID: string, checks: $ReadOnlyArray, ): Promise { const validThreads = await checkThreads(viewer, [threadID], checks); return validThreads.has(threadID); } // We pass this into getRoleForPermissions in order to check if a hypothetical // permissions blob would block membership by returning a non-positive result. // It doesn't matter what value we pass in, as long as it's positive. const arbitraryPositiveRole = '1'; type ContainingStatus = 'member' | 'non-member' | 'no-containing-thread'; type CandidateMembers = { +[key: string]: ?$ReadOnlyArray, }; type ValidateCandidateMembersParams = { +threadType: ThreadType, +parentThreadID: ?string, +containingThreadID: ?string, +defaultRolePermissions: ThreadRolePermissionsBlob, + +communityID: ?string, }; type ValidateCandidateMembersOptions = { +requireRelationship?: boolean }; async function validateCandidateMembers( viewer: Viewer, candidates: CandidateMembers, params: ValidateCandidateMembersParams, options?: ValidateCandidateMembersOptions, ): Promise { const requireRelationship = options?.requireRelationship ?? true; const allCandidatesSet = new Set(); for (const key in candidates) { const candidateGroup = candidates[key]; if (!candidateGroup) { continue; } for (const candidate of candidateGroup) { allCandidatesSet.add(candidate); } } const allCandidates = [...allCandidatesSet]; const fetchMembersPromise = fetchKnownUserInfos(viewer, allCandidates); const parentPermissionsPromise = (async () => { const parentPermissions = {}; if (!params.parentThreadID || allCandidates.length === 0) { return parentPermissions; } const parentPermissionsQuery = SQL` SELECT user, permissions FROM memberships WHERE thread = ${params.parentThreadID} AND user IN (${allCandidates}) `; const [result] = await dbQuery(parentPermissionsQuery); for (const row of result) { parentPermissions[row.user.toString()] = JSON.parse(row.permissions); } return parentPermissions; })(); const memberOfContainingThreadPromise: Promise< Map, > = (async () => { const results = new Map(); if (allCandidates.length === 0) { return results; } if (!params.containingThreadID) { for (const userID of allCandidates) { results.set(userID, 'no-containing-thread'); } return results; } for (const userID of allCandidates) { results.set(userID, 'non-member'); } const memberOfContainingThreadQuery = SQL` SELECT user, role AS containing_role FROM memberships WHERE thread = ${params.containingThreadID} AND user IN (${allCandidates}) `; const [result] = await dbQuery(memberOfContainingThreadQuery); for (const row of result) { results.set( row.user.toString(), row.containing_role > 0 ? 'member' : 'non-member', ); } return results; })(); const [fetchedMembers, parentPermissions, memberOfContainingThread] = await Promise.all([ fetchMembersPromise, parentPermissionsPromise, memberOfContainingThreadPromise, ]); const ignoreMembers = new Set(); for (const memberID of allCandidates) { const member = fetchedMembers[memberID]; if (!member && requireRelationship) { ignoreMembers.add(memberID); continue; } const relationshipStatus = member?.relationshipStatus; const memberRelationshipHasBlock = !!( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ); if (memberRelationshipHasBlock) { ignoreMembers.add(memberID); continue; } const permissionsFromParent = parentPermissions[memberID]; - if (memberOfContainingThread.get(memberID) === 'non-member') { + if ( + memberOfContainingThread.get(memberID) === 'non-member' && + (params.communityID !== genesis().id || + (relationshipStatus !== userRelationshipStatus.FRIEND && + requireRelationship)) + ) { ignoreMembers.add(memberID); continue; } - const isParentThreadGenesis = params.parentThreadID === genesis().id; if ( - (memberOfContainingThread.get(memberID) === 'no-containing-thread' || - isParentThreadGenesis) && + memberOfContainingThread.get(memberID) === 'no-containing-thread' && relationshipStatus !== userRelationshipStatus.FRIEND && requireRelationship ) { ignoreMembers.add(memberID); continue; } const permissions = makePermissionsBlob( params.defaultRolePermissions, permissionsFromParent, '-1', params.threadType, ); if (!permissions) { ignoreMembers.add(memberID); continue; } const targetRole = getRoleForPermissions( arbitraryPositiveRole, permissions, ); if (Number(targetRole) <= 0) { ignoreMembers.add(memberID); continue; } } if (ignoreMembers.size === 0) { return candidates; } const result: { [string]: ?$ReadOnlyArray } = {}; for (const key in candidates) { const candidateGroup = candidates[key]; if (!candidateGroup) { result[key] = candidateGroup; continue; } const resultForKey = []; for (const candidate of candidateGroup) { if (!ignoreMembers.has(candidate)) { resultForKey.push(candidate); } } result[key] = resultForKey; } return result; } export { fetchThreadPermissionsBlob, checkThreadPermission, viewerIsMember, checkThreads, getValidThreads, checkThread, checkIfThreadIsBlocked, validateCandidateMembers, }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index a6ca2c0d1..f13667ed5 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,1005 +1,1013 @@ // @flow import { specialRoles } from 'lib/permissions/special-roles.js'; import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawMessageInfo, MessageData } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { canToggleMessagePin } from 'lib/utils/message-pinning-utils.js'; import { promiseAll, ignorePromiseRejections } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { validChatNameRegex } from 'lib/utils/validation-utils.js'; import { reportLinkUsage } from './link-updaters.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, type MembershipChangeset, type MembershipRow, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { checkIfCommunityHasFarcasterChannelTag } from '../fetchers/community-fetchers.js'; import { checkIfInviteLinkIsValid } from '../fetchers/link-fetchers.js'; import { fetchMessageInfoByID } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, rawThreadInfosFromServerThreadInfos, + determineThreadAncestryForPossibleMemberResolution, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; type UpdateRoleOptions = { +silenceNewMessages?: boolean, +forcePermissionRecalculation?: boolean, }; async function updateRole( viewer: Viewer, request: RoleChangeRequest, options?: UpdateRoleOptions, ): Promise { const silenceNewMessages = options?.silenceNewMessages; const forcePermissionRecalculation = options?.forcePermissionRecalculation; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission, fetchThreadResult] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), fetchThreadInfos(viewer, { threadID: request.threadID }), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!threadInfo) { throw new ServerError('invalid_parameters'); } const adminRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === 'Admins', ); // Ensure that there will always still be at least one admin in a community if (adminRoleID) { const memberRoles = memberIDs.map( memberID => threadInfo.members.find(member => member.id === memberID)?.role, ); const communityAdminsCount = threadInfo.members.filter( member => member.role === adminRoleID, ).length; const changedAdminsCount = memberRoles.filter( memberRole => memberRole === adminRoleID, ).length; if (changedAdminsCount >= communityAdminsCount) { throw new ServerError('invalid_parameters'); } } 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, { forcePermissionRecalculation: !!forcePermissionRecalculation, }, ); const { viewerUpdates } = await commitMembershipChangeset( viewer, changeset, forcePermissionRecalculation ? { changedThreadIDs: new Set([request.threadID]) } : undefined, ); let newMessageInfos: Array = []; if (!silenceNewMessages) { const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, roleName: threadInfo.roles[request.role].name, }; newMessageInfos = await createMessages(viewer, [messageData]); } return { 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, r.id AS default_role FROM memberships m LEFT JOIN roles r ON r.special_role = ${specialRoles.DEFAULT_ROLE} AND r.thread = ${request.threadID} WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); return { 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, { threadID: request.threadID }), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { return { updatesResult: { newUpdates: [] }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); return { updatesResult: { newUpdates: viewerUpdates } }; } type UpdateThreadOptions = Partial<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const changedFields: { [string]: string | number } = {}; const sqlUpdate: { [string]: ?string | number } = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } const serverThreadInfosPromise = fetchServerThreadInfos({ threadID: request.threadID, }); const hasNecessaryPermissionsPromise = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const [serverThreadInfos] = await Promise.all([ serverThreadInfosPromise, hasNecessaryPermissionsPromise, ]); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.special_role = ${specialRoles.DEFAULT_ROLE} AND r.thread = ${request.threadID} WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); + const containingThreadIDForPossibleMemberResolution = + determineThreadAncestryForPossibleMemberResolution( + nextParentThreadID, + nextThreadAncestry.containingThreadID, + ); + const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, - containingThreadID: nextThreadAncestry.containingThreadID, + containingThreadID: containingThreadIDForPossibleMemberResolution, defaultRolePermissions, + communityID: nextThreadAncestry.community, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = avatar && (avatar.type === 'image' || avatar.type === 'encrypted_image') ? avatar.uploadID : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND user_container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND user_container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const addMembersChangesetPromise: Promise = (async () => { if (!newMemberIDs) { return undefined; } await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); const recalculatePermissionsChangesetPromise: Promise = (async () => { const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (!threadRootChanged) { return undefined; } await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); const [addMembersChangeset, recalculatePermissionsChangeset] = await Promise.all([ addMembersChangesetPromise, recalculatePermissionsChangesetPromise, updateQueryPromise, updateRolesPromise, ]); const membershipRows: Array = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { 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(), // last_message will be updated automatically if we send a message, // so we only need to handle it here when we silence new messages updateMembershipsLastMessage: silenceMessages, }); let newMessageInfos: Array = []; if (!silenceMessages) { const time = Date.now(); const messageDatas: Array = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const permissionPromise = (async () => { if (request.inviteLinkSecret) { return await checkIfInviteLinkIsValid( request.inviteLinkSecret, request.threadID, ); } const threadPermissionPromise = checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ); const communityFarcasterChannelTagPromise = checkIfCommunityHasFarcasterChannelTag(viewer, request.threadID); const [threadPermission, hasCommunityFarcasterChannelTag] = await Promise.all([ threadPermissionPromise, communityFarcasterChannelTagPromise, ]); return threadPermission || hasCommunityFarcasterChannelTag; })(); const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), permissionPromise, ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null, { defaultSubscription: request.defaultSubscription, }); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); if (request.inviteLinkSecret) { ignorePromiseRejections(reportLinkUsage(request.inviteLinkSecret)); } const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const fetchServerThreadInfosResult = await fetchServerThreadInfos({ threadID, }); const { threadInfos: rawThreadInfos } = rawThreadInfosFromServerThreadInfos( viewer, fetchServerThreadInfosResult, ); const rawThreadInfo = rawThreadInfos[threadID]; const canTogglePin = canToggleMessagePin(targetMessage, rawThreadInfo); if (!canTogglePin) { throw new ServerError('invalid_parameters'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const pinnedCountValue = action === 'pin' ? 1 : -1; const query = SQL` UPDATE messages AS m, threads AS t SET m.pinned = ${pinnedValue}, m.pin_time = ${pinTimeValue}, t.pinned_count = t.pinned_count + ${pinnedCountValue} WHERE m.id = ${messageID} AND m.thread = ${threadID} AND t.id = ${threadID} AND m.pinned != ${pinnedValue} `; const [result] = await dbQuery(query); if (result.affectedRows === 0) { return { newMessageInfos: [], threadID, }; } const createMessagesAsync = async () => { const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; const newMessageInfos = await createMessages(viewer, [messageData]); return newMessageInfos; }; const createUpdatesAsync = async () => { const { threadInfos: serverThreadInfos } = fetchServerThreadInfosResult; const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); }; const [newMessageInfos] = await Promise.all([ createMessagesAsync(), createUpdatesAsync(), ]); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, toggleMessagePinForThread, };