diff --git a/keyserver/src/creators/role-creator.js b/keyserver/src/creators/role-creator.js index 4f779759c..396bb817f 100644 --- a/keyserver/src/creators/role-creator.js +++ b/keyserver/src/creators/role-creator.js @@ -1,319 +1,61 @@ // @flow -import { - type RoleInfo, - threadPermissions, - threadPermissionPropagationPrefixes, - threadPermissionFilterPrefixes, - type ThreadRolePermissionsBlob, - type ThreadType, - threadTypes, -} from 'lib/types/thread-types.js'; +import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; +import type { RoleInfo, ThreadType } from 'lib/types/thread-types.js'; import createIDs from './id-creator.js'; import { dbQuery, SQL } from '../database/database.js'; type InitialRoles = { +default: RoleInfo, +creator: RoleInfo, }; async function createInitialRolesForNewThread( threadID: string, threadType: ThreadType, ): Promise { const rolePermissions = getRolePermissionBlobs(threadType); const ids = await createIDs('roles', Object.values(rolePermissions).length); const time = Date.now(); const newRows = []; const namesToIDs = {}; for (const name in rolePermissions) { const id = ids.shift(); namesToIDs[name] = id; const permissionsBlob = JSON.stringify(rolePermissions[name]); newRows.push([id, threadID, name, permissionsBlob, time]); } const query = SQL` INSERT INTO roles (id, thread, name, permissions, creation_time) VALUES ${newRows} `; await dbQuery(query); const defaultRoleInfo = { id: namesToIDs.Members, name: 'Members', permissions: rolePermissions.Members, isDefault: true, }; if (!rolePermissions.Admins) { return { default: defaultRoleInfo, creator: defaultRoleInfo, }; } const adminRoleInfo = { id: namesToIDs.Admins, name: 'Admins', permissions: rolePermissions.Admins, isDefault: false, }; return { default: defaultRoleInfo, creator: adminRoleInfo, }; } -type RolePermissionBlobs = { - +Members: ThreadRolePermissionsBlob, - +Admins?: ThreadRolePermissionsBlob, -}; - -const { CHILD, DESCENDANT } = threadPermissionPropagationPrefixes; -const { OPEN, TOP_LEVEL, OPEN_TOP_LEVEL } = threadPermissionFilterPrefixes; -const OPEN_CHILD = CHILD + OPEN; -const OPEN_DESCENDANT = DESCENDANT + OPEN; -const TOP_LEVEL_DESCENDANT = DESCENDANT + TOP_LEVEL; -const OPEN_TOP_LEVEL_DESCENDANT = DESCENDANT + OPEN_TOP_LEVEL; - -const voicedPermissions = { - [threadPermissions.VOICED]: true, - [threadPermissions.EDIT_ENTRIES]: true, - [threadPermissions.EDIT_THREAD_NAME]: true, - [threadPermissions.EDIT_THREAD_COLOR]: true, - [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, - [threadPermissions.EDIT_THREAD_AVATAR]: true, - [threadPermissions.CREATE_SUBCHANNELS]: true, - [threadPermissions.ADD_MEMBERS]: true, -}; - -function getRolePermissionBlobsForCommunity( - threadType: ThreadType, -): RolePermissionBlobs { - const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; - const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; - const openTopLevelDescendantJoinThread = - OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; - const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; - const openChildAddMembers = OPEN_CHILD + threadPermissions.ADD_MEMBERS; - - const genesisMemberPermissions = { - [threadPermissions.KNOW_OF]: true, - [threadPermissions.VISIBLE]: true, - [openDescendantKnowOf]: true, - [openDescendantVisible]: true, - [openTopLevelDescendantJoinThread]: true, - }; - const baseMemberPermissions = { - ...genesisMemberPermissions, - [threadPermissions.REACT_TO_MESSAGE]: true, - [threadPermissions.EDIT_MESSAGE]: true, - [threadPermissions.LEAVE_THREAD]: true, - [threadPermissions.CREATE_SIDEBARS]: true, - [threadPermissions.ADD_MEMBERS]: true, - [openChildJoinThread]: true, - [openChildAddMembers]: true, - }; - - let memberPermissions; - if (threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT) { - memberPermissions = baseMemberPermissions; - } else if (threadType === threadTypes.GENESIS) { - memberPermissions = genesisMemberPermissions; - } else { - memberPermissions = { - ...baseMemberPermissions, - ...voicedPermissions, - }; - } - - const descendantKnowOf = DESCENDANT + threadPermissions.KNOW_OF; - const descendantVisible = DESCENDANT + threadPermissions.VISIBLE; - const topLevelDescendantJoinThread = - TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; - const childJoinThread = CHILD + threadPermissions.JOIN_THREAD; - const descendantVoiced = DESCENDANT + threadPermissions.VOICED; - const descendantEditEntries = DESCENDANT + threadPermissions.EDIT_ENTRIES; - const descendantEditThreadName = - DESCENDANT + threadPermissions.EDIT_THREAD_NAME; - const descendantEditThreadColor = - DESCENDANT + threadPermissions.EDIT_THREAD_COLOR; - const descendantEditThreadDescription = - DESCENDANT + threadPermissions.EDIT_THREAD_DESCRIPTION; - const descendantEditThreadAvatar = - DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR; - const topLevelDescendantCreateSubchannels = - TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SUBCHANNELS; - const topLevelDescendantCreateSidebars = - TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SIDEBARS; - const descendantAddMembers = DESCENDANT + threadPermissions.ADD_MEMBERS; - const descendantDeleteThread = DESCENDANT + threadPermissions.DELETE_THREAD; - const descendantEditPermissions = - DESCENDANT + threadPermissions.EDIT_PERMISSIONS; - const descendantRemoveMembers = DESCENDANT + threadPermissions.REMOVE_MEMBERS; - const descendantChangeRole = DESCENDANT + threadPermissions.CHANGE_ROLE; - const descendantManagePins = DESCENDANT + threadPermissions.MANAGE_PINS; - - const baseAdminPermissions = { - [threadPermissions.KNOW_OF]: true, - [threadPermissions.VISIBLE]: true, - [threadPermissions.VOICED]: true, - [threadPermissions.REACT_TO_MESSAGE]: true, - [threadPermissions.EDIT_MESSAGE]: true, - [threadPermissions.EDIT_ENTRIES]: true, - [threadPermissions.EDIT_THREAD_NAME]: true, - [threadPermissions.EDIT_THREAD_COLOR]: true, - [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, - [threadPermissions.EDIT_THREAD_AVATAR]: true, - [threadPermissions.CREATE_SUBCHANNELS]: true, - [threadPermissions.CREATE_SIDEBARS]: true, - [threadPermissions.ADD_MEMBERS]: true, - [threadPermissions.DELETE_THREAD]: true, - [threadPermissions.REMOVE_MEMBERS]: true, - [threadPermissions.CHANGE_ROLE]: true, - [threadPermissions.MANAGE_PINS]: true, - [descendantKnowOf]: true, - [descendantVisible]: true, - [topLevelDescendantJoinThread]: true, - [childJoinThread]: true, - [descendantVoiced]: true, - [descendantEditEntries]: true, - [descendantEditThreadName]: true, - [descendantEditThreadColor]: true, - [descendantEditThreadDescription]: true, - [descendantEditThreadAvatar]: true, - [topLevelDescendantCreateSubchannels]: true, - [topLevelDescendantCreateSidebars]: true, - [descendantAddMembers]: true, - [descendantDeleteThread]: true, - [descendantEditPermissions]: true, - [descendantRemoveMembers]: true, - [descendantChangeRole]: true, - [descendantManagePins]: true, - }; - - let adminPermissions; - if (threadType === threadTypes.GENESIS) { - adminPermissions = baseAdminPermissions; - } else { - adminPermissions = { - ...baseAdminPermissions, - [threadPermissions.LEAVE_THREAD]: true, - }; - } - - return { - Members: memberPermissions, - Admins: adminPermissions, - }; -} - -function getRolePermissionBlobs(threadType: ThreadType): RolePermissionBlobs { - if (threadType === threadTypes.SIDEBAR) { - const memberPermissions = { - [threadPermissions.VOICED]: true, - [threadPermissions.REACT_TO_MESSAGE]: true, - [threadPermissions.EDIT_MESSAGE]: true, - [threadPermissions.EDIT_THREAD_NAME]: true, - [threadPermissions.EDIT_THREAD_COLOR]: true, - [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, - [threadPermissions.EDIT_THREAD_AVATAR]: true, - [threadPermissions.ADD_MEMBERS]: true, - [threadPermissions.EDIT_PERMISSIONS]: true, - [threadPermissions.REMOVE_MEMBERS]: true, - [threadPermissions.LEAVE_THREAD]: true, - }; - return { - Members: memberPermissions, - }; - } - - const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; - const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; - const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; - - if (threadType === threadTypes.PRIVATE) { - const memberPermissions = { - [threadPermissions.KNOW_OF]: true, - [threadPermissions.VISIBLE]: true, - [threadPermissions.VOICED]: true, - [threadPermissions.REACT_TO_MESSAGE]: true, - [threadPermissions.EDIT_MESSAGE]: true, - [threadPermissions.EDIT_THREAD_COLOR]: true, - [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, - [threadPermissions.CREATE_SIDEBARS]: true, - [threadPermissions.EDIT_ENTRIES]: true, - [openDescendantKnowOf]: true, - [openDescendantVisible]: true, - [openChildJoinThread]: true, - }; - return { - Members: memberPermissions, - }; - } - - if (threadType === threadTypes.PERSONAL) { - return { - Members: { - [threadPermissions.KNOW_OF]: true, - [threadPermissions.VISIBLE]: true, - [threadPermissions.VOICED]: true, - [threadPermissions.REACT_TO_MESSAGE]: true, - [threadPermissions.EDIT_MESSAGE]: true, - [threadPermissions.EDIT_ENTRIES]: true, - [threadPermissions.EDIT_THREAD_NAME]: true, - [threadPermissions.EDIT_THREAD_COLOR]: true, - [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, - [threadPermissions.CREATE_SIDEBARS]: true, - [openDescendantKnowOf]: true, - [openDescendantVisible]: true, - [openChildJoinThread]: true, - }, - }; - } - - const openTopLevelDescendantJoinThread = - OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; - - const subthreadBasePermissions = { - [threadPermissions.KNOW_OF]: true, - [threadPermissions.VISIBLE]: true, - [threadPermissions.REACT_TO_MESSAGE]: true, - [threadPermissions.EDIT_MESSAGE]: true, - [threadPermissions.CREATE_SIDEBARS]: true, - [threadPermissions.LEAVE_THREAD]: true, - [openDescendantKnowOf]: true, - [openDescendantVisible]: true, - [openTopLevelDescendantJoinThread]: true, - [openChildJoinThread]: true, - }; - - if ( - threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || - threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD - ) { - const memberPermissions = { - [threadPermissions.REMOVE_MEMBERS]: true, - [threadPermissions.EDIT_PERMISSIONS]: true, - ...subthreadBasePermissions, - ...voicedPermissions, - }; - return { - Members: memberPermissions, - }; - } - - if ( - threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || - threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD - ) { - return { - Members: subthreadBasePermissions, - }; - } - - return getRolePermissionBlobsForCommunity(threadType); -} - -export { createInitialRolesForNewThread, getRolePermissionBlobs }; +export { createInitialRolesForNewThread }; diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index 0961af69c..10796f87a 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,524 +1,522 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; +import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/color-utils.js'; import { getThreadTypeParentRequirement } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types.js'; import { type ServerNewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, threadTypeIsCommunityRoot, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; -import { - createInitialRolesForNewThread, - getRolePermissionBlobs, -} from './role-creator.js'; +import { createInitialRolesForNewThread } from './role-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchLatestEditMessageContentByID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { determineThreadAncestry, personalThreadQuery, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, } from '../updaters/thread-permission-updaters.js'; import { joinThread } from '../updaters/thread-updaters.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Shape<{ +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, +silentlyFailMembers: boolean, }>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: ServerNewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; let parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? [...new Set(request.initialMemberIDs)] : null; const ghostMemberIDsFromRequest = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? [...new Set(request.ghostMemberIDs)] : null; const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const parentRequirement = getThreadTypeParentRequirement(threadType); if ( (parentRequirement === 'required' && !parentThreadID) || (parentRequirement === 'disabled' && parentThreadID) ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && request.initialMemberIDs?.length !== 1 ) { throw new ServerError('invalid_parameters'); } const requestParentThreadID = parentThreadID; const confirmParentPermissionPromise = (async () => { if (!requestParentThreadID) { return; } const hasParentPermission = await checkThreadPermission( viewer, requestParentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } })(); // This is a temporary hack until we release actual E2E-encrypted local // conversations. For now we are hosting all root threads on Ashoat's // keyserver, so we set them to the have the Genesis community as their // parent thread. if (!parentThreadID && !threadTypeIsCommunityRoot(threadType)) { parentThreadID = genesis.id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, containingThreadID: threadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !shouldCreateRelationships }, ); if ( !silentlyFailMembers && (Number(initialMemberIDs?.length) < Number(initialMemberIDsFromRequest?.length) || Number(ghostMemberIDs?.length) < Number(ghostMemberIDsFromRequest?.length)) ) { throw new ServerError('invalid_credentials'); } return { initialMemberIDs, ghostMemberIDs }; })(); const checkPromises = {}; checkPromises.confirmParentPermission = confirmParentPermissionPromise; checkPromises.threadAncestry = determineThreadAncestryPromise; checkPromises.validateMembers = validateMembersPromise; if (sourceMessageID) { checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); } const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll(checkPromises); if ( sourceMessage && (sourceMessage.type === messageTypes.REACTION || sourceMessage.type === messageTypes.EDIT_MESSAGE) ) { throw new ServerError('invalid_parameters'); } let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor([ ...(request.initialMemberIDs ?? []), viewer.id, ]); } const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, threadAncestry.containingThreadID, threadAncestry.community, threadAncestry.depth, newRoles.default.id, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) SELECT ${row} WHERE NOT EXISTS (`; query.append(existingThreadQuery).append(SQL`)`); const [result] = await dbQuery(query); if (result.affectedRows === 0) { const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[existingThreadResult]] = await Promise.all([ dbQuery(existingThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant(existingThreadResult.length > 0, 'thread should exist'); const existingThreadID = existingThreadResult[0].id.toString(); let calendarQuery; if (hasMinCodeVersion(viewer.platformDetails, 87)) { invariant(request.calendarQuery, 'calendar query should exist'); calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; } let joinUpdateInfos = []; let userInfos: UserInfos = {}; let newMessageInfos = []; if (threadType !== threadTypes.PERSONAL) { const joinThreadResult = await joinThread(viewer, { threadID: existingThreadID, calendarQuery, }); joinUpdateInfos = joinThreadResult.updatesResult.newUpdates; userInfos = joinThreadResult.userInfos; newMessageInfos = joinThreadResult.rawMessageInfos; } const { viewerUpdates: newUpdates, userInfos: changesetUserInfos } = await getChangesetCommitResultForExistingThread( viewer, existingThreadID, joinUpdateInfos, { calendarQuery, updatesForCurrentSession }, ); userInfos = { ...userInfos, ...changesetUserInfos }; return { newThreadID: existingThreadID, updatesResult: { newUpdates, }, userInfos, newMessageInfos, }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } let initialMemberPromise; if (initialMemberIDs) { initialMemberPromise = changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true, }); } let ghostMemberPromise; if (ghostMemberIDs) { ghostMemberPromise = changeRole(id, ghostMemberIDs, -1); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberPromise, ghostMemberPromise, recalculateThreadPermissions(id), ]); const { membershipRows: creatorMembershipRows, relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipChangeset = new RelationshipChangeset(); relationshipChangeset.addAll(creatorRelationshipChangeset); relationshipChangeset.addAll(recalculateRelationshipChangeset); if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipChangeset: initialMembersRelationshipChangeset, } = initialMembersChangeset; pushAll(membershipRows, initialMembersMembershipRows); relationshipChangeset.addAll(initialMembersRelationshipChangeset); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipChangeset: ghostMembersRelationshipChangeset, } = ghostMembersChangeset; pushAll(membershipRows, ghostMembersMembershipRows); relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates, userInfos } = await commitMembershipChangeset(viewer, changeset, { updatesForCurrentSession, }); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); if (!sourceMessage || sourceMessage.type === messageTypes.SIDEBAR_SOURCE) { throw new ServerError('invalid_parameters'); } let editedSourceMessage = sourceMessage; if (sourceMessageID && sourceMessage.type === messageTypes.TEXT) { const editMessageContent = await fetchLatestEditMessageContentByID( sourceMessageID, ); if (editMessageContent) { editedSourceMessage = { ...sourceMessage, text: editMessageContent.text, }; } } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage: editedSourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if ( parentThreadID && threadType !== threadTypes.SIDEBAR && (parentThreadID !== genesis.id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); if (hasMinCodeVersion(viewer.platformDetails, 62)) { 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/updaters/role-updaters.js b/keyserver/src/updaters/role-updaters.js index bbae7caab..1c6011309 100644 --- a/keyserver/src/updaters/role-updaters.js +++ b/keyserver/src/updaters/role-updaters.js @@ -1,109 +1,115 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; +import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import type { ThreadType } from 'lib/types/thread-types.js'; import createIDs from '../creators/id-creator.js'; -import { getRolePermissionBlobs } from '../creators/role-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchRoles } from '../fetchers/role-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function updateRoles( viewer: Viewer, threadID: string, threadType: ThreadType, ): Promise { const currentRoles = await fetchRoles(threadID); const currentRolePermissions = {}; const currentRoleIDs = {}; for (const roleInfo of currentRoles) { currentRolePermissions[roleInfo.name] = roleInfo.permissions; currentRoleIDs[roleInfo.name] = roleInfo.id; } const rolePermissions = getRolePermissionBlobs(threadType); if (_isEqual(rolePermissions)(currentRolePermissions)) { return; } const promises = []; if (rolePermissions.Admins && !currentRolePermissions.Admins) { const [id] = await createIDs('roles', 1); const newRow = [ id, threadID, 'Admins', JSON.stringify(rolePermissions.Admins), Date.now(), ]; const insertQuery = SQL` INSERT INTO roles (id, thread, name, permissions, creation_time) VALUES ${[newRow]} `; promises.push(dbQuery(insertQuery)); const setAdminQuery = SQL` UPDATE memberships SET role = ${id} - WHERE thread = ${threadID} AND user = ${viewer.userID} AND role > 0 + WHERE thread = ${threadID} + AND user = ${viewer.userID} + AND role > 0 `; promises.push(dbQuery(setAdminQuery)); } else if (!rolePermissions.Admins && currentRolePermissions.Admins) { invariant( currentRoleIDs.Admins && currentRoleIDs.Members, 'ids should exist for both Admins and Members roles', ); const id = currentRoleIDs.Admins; const deleteQuery = SQL` - DELETE r, i FROM roles r LEFT JOIN ids i ON i.id = r.id WHERE r.id = ${id} + DELETE r, i + FROM roles r + LEFT JOIN ids i ON i.id = r.id + WHERE r.id = ${id} `; promises.push(dbQuery(deleteQuery)); const updateMembershipsQuery = SQL` UPDATE memberships SET role = ${currentRoleIDs.Members} - WHERE thread = ${threadID} AND role > 0 + WHERE thread = ${threadID} + AND role > 0 `; promises.push(dbQuery(updateMembershipsQuery)); } const updatePermissions = {}; for (const name in currentRoleIDs) { const currentPermissions = currentRolePermissions[name]; const permissions = rolePermissions[name]; if ( !permissions || !currentPermissions || _isEqual(permissions)(currentPermissions) ) { continue; } const id = currentRoleIDs[name]; updatePermissions[id] = permissions; } if (Object.values(updatePermissions).length > 0) { const updateQuery = SQL` UPDATE roles SET permissions = CASE id `; for (const id in updatePermissions) { const permissionsBlob = JSON.stringify(updatePermissions[id]); updateQuery.append(SQL` WHEN ${id} THEN ${permissionsBlob} `); } updateQuery.append(SQL` ELSE permissions END WHERE thread = ${threadID} `); promises.push(dbQuery(updateQuery)); } await Promise.all(promises); } export { updateRoles }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index 63111cb47..6bd11074e 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,1017 +1,1021 @@ // @flow +import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, validChatNameRegex, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes, defaultNumberPerThread, } from 'lib/types/message-types.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, threadPermissions, threadTypes, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; -import { getRolePermissionBlobs } from '../creators/role-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships - WHERE user IN (${memberIDs}) AND thread = ${request.threadID} + WHERE user IN (${memberIDs}) + AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { 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} + WHERE m.user IN (${memberIDs}) + AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: [] }, }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: [], }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); validationPromises.hasNecessaryPermissions = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const { serverThreadInfos } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: nextThreadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = avatar && avatar.type === 'image' ? avatar.uploadID : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; intermediatePromises.updateQuery = updateQueryPromise; intermediatePromises.updateRoles = updateRolesPromise; if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); } const { addMembersChangeset, recalculatePermissionsChangeset } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); let newMessageInfos = []; if (!silenceMessages) { const time = Date.now(); const messageDatas = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } if (hasMinCodeVersion(viewer.platformDetails, 62)) { 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 (!hasPermission) { throw new ServerError('invalid_parameters'); } // TODO: determine code version const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); const hasCodeVersionBelow62 = !hasMinCodeVersion(viewer.platformDetails, 62); const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; if (calendarQuery && hasCodeVersionBelow87) { response.rawEntryInfos = []; } if (hasCodeVersionBelow62) { response.threadInfos = {}; } return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); const messageSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; if (!hasCodeVersionBelow87) { return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (hasCodeVersionBelow62) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.MANAGE_PINS, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const togglePinQuery = SQL` UPDATE messages - SET pinned = ${pinnedValue}, pin_time = ${pinTimeValue} - WHERE id = ${messageID} AND thread = ${threadID} + SET pinned = ${pinnedValue}, + pin_time = ${pinTimeValue} + WHERE id = ${messageID} + AND thread = ${threadID} `; const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; let updateThreadQuery; if (action === 'pin') { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count + 1 WHERE id = ${threadID} `; } else { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count - 1 WHERE id = ${threadID} `; } const [{ threadInfos: serverThreadInfos }] = await Promise.all([ fetchServerThreadInfos(SQL`t.id = ${threadID}`), dbQuery(togglePinQuery), dbQuery(updateThreadQuery), ]); const newMessageInfos = await createMessages(viewer, [messageData]); const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, toggleMessagePinForThread, }; diff --git a/lib/permissions/thread-permissions.js b/lib/permissions/thread-permissions.js index 54bca579f..512b31889 100644 --- a/lib/permissions/thread-permissions.js +++ b/lib/permissions/thread-permissions.js @@ -1,159 +1,413 @@ // @flow import { parseThreadPermissionString, includeThreadPermissionForThreadType, } from './prefixes.js'; import { type ThreadPermissionsBlob, type ThreadType, type ThreadPermission, type ThreadRolePermissionsBlob, type ThreadPermissionInfo, type ThreadPermissionsInfo, threadPermissions, threadPermissionPropagationPrefixes, + threadPermissionFilterPrefixes, + threadTypes, } from '../types/thread-types.js'; function permissionLookup( permissions: ?ThreadPermissionsBlob | ?ThreadPermissionsInfo, permission: ThreadPermission, ): boolean { return !!( permissions && permissions[permission] && permissions[permission].value && permissions[threadPermissions.KNOW_OF] && permissions[threadPermissions.KNOW_OF].value ); } function getAllThreadPermissions( permissions: ?ThreadPermissionsBlob, threadID: string, ): ThreadPermissionsInfo { const result = {}; for (const permissionName in threadPermissions) { const permissionKey = threadPermissions[permissionName]; const permission = permissionLookup(permissions, permissionKey); let source = null; if (permission) { if (permissions && permissions[permissionKey]) { source = permissions[permissionKey].source; } else { source = threadID; } } result[permissionKey] = { value: permission, source }; } return result; } // - rolePermissions can be null if role <= 0, ie. not a member // - permissionsFromParent can be null if there are no permissions from the // parent // - return can be null if no permissions exist function makePermissionsBlob( rolePermissions: ?ThreadRolePermissionsBlob, permissionsFromParent: ?ThreadPermissionsBlob, threadID: string, threadType: ThreadType, ): ?ThreadPermissionsBlob { let permissions = {}; if (permissionsFromParent) { for (const permissionKey in permissionsFromParent) { const permissionValue = permissionsFromParent[permissionKey]; const parsed = parseThreadPermissionString(permissionKey); if (!includeThreadPermissionForThreadType(parsed, threadType)) { continue; } if (parsed.propagationPrefix) { permissions[permissionKey] = permissionValue; } else { permissions[parsed.permission] = permissionValue; } } } const combinedPermissions: { [permission: string]: ThreadPermissionInfo, } = { ...permissions }; if (rolePermissions) { for (const permissionKey in rolePermissions) { const permissionValue = rolePermissions[permissionKey]; const currentValue = combinedPermissions[permissionKey]; if (permissionValue) { combinedPermissions[permissionKey] = { value: true, source: threadID, }; } else if (!currentValue || !currentValue.value) { combinedPermissions[permissionKey] = { value: false, source: null, }; } } } if (permissionLookup(combinedPermissions, threadPermissions.KNOW_OF)) { permissions = combinedPermissions; } if (Object.keys(permissions).length === 0) { return null; } return permissions; } function makePermissionsForChildrenBlob( permissions: ?ThreadPermissionsBlob, ): ?ThreadPermissionsBlob { if (!permissions) { return null; } const permissionsForChildren = {}; for (const permissionKey in permissions) { const permissionValue = permissions[permissionKey]; const parsed = parseThreadPermissionString(permissionKey); if (!parsed.propagationPrefix) { continue; } if ( parsed.propagationPrefix === threadPermissionPropagationPrefixes.DESCENDANT ) { permissionsForChildren[permissionKey] = permissionValue; } const permissionWithFilterPrefix = parsed.filterPrefix ? `${parsed.filterPrefix}${parsed.permission}` : parsed.permission; permissionsForChildren[permissionWithFilterPrefix] = permissionValue; } if (Object.keys(permissionsForChildren).length === 0) { return null; } return permissionsForChildren; } function getRoleForPermissions( inputRole: string, permissions: ?ThreadPermissionsBlob, ): string { if (!permissionLookup(permissions, threadPermissions.KNOW_OF)) { return '-1'; } else if (Number(inputRole) <= 0) { return '0'; } else { return inputRole; } } +type RolePermissionBlobs = { + +Members: ThreadRolePermissionsBlob, + +Admins?: ThreadRolePermissionsBlob, +}; + +const { CHILD, DESCENDANT } = threadPermissionPropagationPrefixes; +const { OPEN, TOP_LEVEL, OPEN_TOP_LEVEL } = threadPermissionFilterPrefixes; +const OPEN_CHILD = CHILD + OPEN; +const OPEN_DESCENDANT = DESCENDANT + OPEN; +const TOP_LEVEL_DESCENDANT = DESCENDANT + TOP_LEVEL; +const OPEN_TOP_LEVEL_DESCENDANT = DESCENDANT + OPEN_TOP_LEVEL; + +const voicedPermissions = { + [threadPermissions.VOICED]: true, + [threadPermissions.EDIT_ENTRIES]: true, + [threadPermissions.EDIT_THREAD_NAME]: true, + [threadPermissions.EDIT_THREAD_COLOR]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.EDIT_THREAD_AVATAR]: true, + [threadPermissions.CREATE_SUBCHANNELS]: true, + [threadPermissions.ADD_MEMBERS]: true, +}; + +function getRolePermissionBlobsForCommunity( + threadType: ThreadType, +): RolePermissionBlobs { + const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; + const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; + const openTopLevelDescendantJoinThread = + OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; + const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; + const openChildAddMembers = OPEN_CHILD + threadPermissions.ADD_MEMBERS; + + const genesisMemberPermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [openDescendantKnowOf]: true, + [openDescendantVisible]: true, + [openTopLevelDescendantJoinThread]: true, + }; + const baseMemberPermissions = { + ...genesisMemberPermissions, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.LEAVE_THREAD]: true, + [threadPermissions.CREATE_SIDEBARS]: true, + [threadPermissions.ADD_MEMBERS]: true, + [openChildJoinThread]: true, + [openChildAddMembers]: true, + }; + + let memberPermissions; + if (threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT) { + memberPermissions = baseMemberPermissions; + } else if (threadType === threadTypes.GENESIS) { + memberPermissions = genesisMemberPermissions; + } else { + memberPermissions = { + ...baseMemberPermissions, + ...voicedPermissions, + }; + } + + const descendantKnowOf = DESCENDANT + threadPermissions.KNOW_OF; + const descendantVisible = DESCENDANT + threadPermissions.VISIBLE; + const topLevelDescendantJoinThread = + TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; + const childJoinThread = CHILD + threadPermissions.JOIN_THREAD; + const descendantVoiced = DESCENDANT + threadPermissions.VOICED; + const descendantEditEntries = DESCENDANT + threadPermissions.EDIT_ENTRIES; + const descendantEditThreadName = + DESCENDANT + threadPermissions.EDIT_THREAD_NAME; + const descendantEditThreadColor = + DESCENDANT + threadPermissions.EDIT_THREAD_COLOR; + const descendantEditThreadDescription = + DESCENDANT + threadPermissions.EDIT_THREAD_DESCRIPTION; + const descendantEditThreadAvatar = + DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR; + const topLevelDescendantCreateSubchannels = + TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SUBCHANNELS; + const topLevelDescendantCreateSidebars = + TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SIDEBARS; + const descendantAddMembers = DESCENDANT + threadPermissions.ADD_MEMBERS; + const descendantDeleteThread = DESCENDANT + threadPermissions.DELETE_THREAD; + const descendantEditPermissions = + DESCENDANT + threadPermissions.EDIT_PERMISSIONS; + const descendantRemoveMembers = DESCENDANT + threadPermissions.REMOVE_MEMBERS; + const descendantChangeRole = DESCENDANT + threadPermissions.CHANGE_ROLE; + const descendantManagePins = DESCENDANT + threadPermissions.MANAGE_PINS; + + const baseAdminPermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.EDIT_ENTRIES]: true, + [threadPermissions.EDIT_THREAD_NAME]: true, + [threadPermissions.EDIT_THREAD_COLOR]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.EDIT_THREAD_AVATAR]: true, + [threadPermissions.CREATE_SUBCHANNELS]: true, + [threadPermissions.CREATE_SIDEBARS]: true, + [threadPermissions.ADD_MEMBERS]: true, + [threadPermissions.DELETE_THREAD]: true, + [threadPermissions.REMOVE_MEMBERS]: true, + [threadPermissions.CHANGE_ROLE]: true, + [threadPermissions.MANAGE_PINS]: true, + [descendantKnowOf]: true, + [descendantVisible]: true, + [topLevelDescendantJoinThread]: true, + [childJoinThread]: true, + [descendantVoiced]: true, + [descendantEditEntries]: true, + [descendantEditThreadName]: true, + [descendantEditThreadColor]: true, + [descendantEditThreadDescription]: true, + [descendantEditThreadAvatar]: true, + [topLevelDescendantCreateSubchannels]: true, + [topLevelDescendantCreateSidebars]: true, + [descendantAddMembers]: true, + [descendantDeleteThread]: true, + [descendantEditPermissions]: true, + [descendantRemoveMembers]: true, + [descendantChangeRole]: true, + [descendantManagePins]: true, + }; + + let adminPermissions; + if (threadType === threadTypes.GENESIS) { + adminPermissions = baseAdminPermissions; + } else { + adminPermissions = { + ...baseAdminPermissions, + [threadPermissions.LEAVE_THREAD]: true, + }; + } + + return { + Members: memberPermissions, + Admins: adminPermissions, + }; +} + +function getRolePermissionBlobs(threadType: ThreadType): RolePermissionBlobs { + if (threadType === threadTypes.SIDEBAR) { + const memberPermissions = { + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.EDIT_THREAD_NAME]: true, + [threadPermissions.EDIT_THREAD_COLOR]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.EDIT_THREAD_AVATAR]: true, + [threadPermissions.ADD_MEMBERS]: true, + [threadPermissions.EDIT_PERMISSIONS]: true, + [threadPermissions.REMOVE_MEMBERS]: true, + [threadPermissions.LEAVE_THREAD]: true, + }; + return { + Members: memberPermissions, + }; + } + + const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; + const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; + const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; + + if (threadType === threadTypes.PRIVATE) { + const memberPermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.EDIT_THREAD_COLOR]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.CREATE_SIDEBARS]: true, + [threadPermissions.EDIT_ENTRIES]: true, + [openDescendantKnowOf]: true, + [openDescendantVisible]: true, + [openChildJoinThread]: true, + }; + return { + Members: memberPermissions, + }; + } + + if (threadType === threadTypes.PERSONAL) { + return { + Members: { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.EDIT_ENTRIES]: true, + [threadPermissions.EDIT_THREAD_NAME]: true, + [threadPermissions.EDIT_THREAD_COLOR]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.CREATE_SIDEBARS]: true, + [openDescendantKnowOf]: true, + [openDescendantVisible]: true, + [openChildJoinThread]: true, + }, + }; + } + + const openTopLevelDescendantJoinThread = + OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; + + const subthreadBasePermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.CREATE_SIDEBARS]: true, + [threadPermissions.LEAVE_THREAD]: true, + [openDescendantKnowOf]: true, + [openDescendantVisible]: true, + [openTopLevelDescendantJoinThread]: true, + [openChildJoinThread]: true, + }; + + if ( + threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || + threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD + ) { + const memberPermissions = { + [threadPermissions.REMOVE_MEMBERS]: true, + [threadPermissions.EDIT_PERMISSIONS]: true, + ...subthreadBasePermissions, + ...voicedPermissions, + }; + return { + Members: memberPermissions, + }; + } + + if ( + threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || + threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD + ) { + return { + Members: subthreadBasePermissions, + }; + } + + return getRolePermissionBlobsForCommunity(threadType); +} + export { permissionLookup, getAllThreadPermissions, makePermissionsBlob, makePermissionsForChildrenBlob, getRoleForPermissions, + getRolePermissionBlobs, };