diff --git a/preload.js b/preload.js index 32c88d467..b66761943 100644 --- a/preload.js +++ b/preload.js @@ -40,6 +40,7 @@ try { window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING = true; window.GV2_ENABLE_CHANGE_PROCESSING = true; window.GV2_ENABLE_STATE_PROCESSING = true; + window.GV2_ENABLE_PRE_JOIN_FETCH = true; window.GV2_MIGRATION_DISABLE_ADD = false; window.GV2_MIGRATION_DISABLE_INVITE = false; diff --git a/ts/groups.ts b/ts/groups.ts index 3d5b3d7fb..a0d84d918 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -343,7 +343,7 @@ export async function getPreJoinGroupInfo( ); return makeRequestWithTemporalRetry({ - logId: `groupv2(${data.id})`, + logId: `getPreJoinInfo/groupv2(${data.id})`, publicParams: Bytes.toBase64(data.publicParams), secretParams: Bytes.toBase64(data.secretParams), request: (sender, options) => @@ -2474,6 +2474,7 @@ export async function joinGroupV2ViaLinkAndMigrate({ secretParams, groupInviteLinkPassword: inviteLinkPassword, + addedBy: undefined, left: true, // Capture previous GroupV1 data for future use @@ -2637,6 +2638,7 @@ export async function respondToGroupV2Migration({ newAttributes: { // Because we're using attributes here, we upgrade this to a v2 group ...attributes, + addedBy: undefined, left: true, members: (conversation.get('members') || []).filter( item => item !== ourUuid && item !== ourNumber @@ -2794,7 +2796,7 @@ const FIVE_MINUTES = 5 * durations.MINUTE; export async function waitThenMaybeUpdateGroup( options: MaybeUpdatePropsType, - { viaSync = false } = {} + { viaFirstStorageSync = false } = {} ): Promise { const { conversation } = options; @@ -2826,7 +2828,7 @@ export async function waitThenMaybeUpdateGroup( await conversation.queueJob('waitThenMaybeUpdateGroup', async () => { try { // And finally try to update the group - await maybeUpdateGroup(options, { viaSync }); + await maybeUpdateGroup(options, { viaFirstStorageSync }); conversation.lastSuccessfulGroupFetch = Date.now(); } catch (error) { @@ -2847,7 +2849,7 @@ export async function maybeUpdateGroup( receivedAt, sentAt, }: MaybeUpdatePropsType, - { viaSync = false } = {} + { viaFirstStorageSync = false } = {} ): Promise { const logId = conversation.idForLogging(); @@ -2865,7 +2867,7 @@ export async function maybeUpdateGroup( await updateGroup( { conversation, receivedAt, sentAt, updates }, - { viaSync } + { viaFirstStorageSync } ); } catch (error) { log.error( @@ -2888,21 +2890,27 @@ async function updateGroup( sentAt?: number; updates: UpdatesResultType; }, - { viaSync = false } = {} + { viaFirstStorageSync = false } = {} ): Promise { const logId = conversation.idForLogging(); const { newAttributes, groupChangeMessages, members } = updates; - const ourUuid = window.textsecure.storage.user.getCheckedUuid(); + const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); const startingRevision = conversation.get('revision'); const endingRevision = newAttributes.revision; - const isInGroup = !updates.newAttributes.left; + const wasMemberOrPending = + conversation.hasMember(ourUuid) || conversation.isMemberPending(ourUuid); + const isMemberOrPending = + !newAttributes.left || + newAttributes.pendingMembersV2?.some(item => item.uuid === ourUuid); + const isMemberOrPendingOrAwaitingApproval = + isMemberOrPending || + newAttributes.pendingAdminApprovalV2?.some(item => item.uuid === ourUuid); + const isInitialDataFetch = - isInGroup && !isNumber(startingRevision) && isNumber(endingRevision); - const justJoinedGroup = - isInGroup && !conversation.hasMember(ourUuid.toString()); + !isNumber(startingRevision) && isNumber(endingRevision); // Ensure that all generated messages are ordered properly. // Before the provided timestamp so update messages appear before the @@ -2916,16 +2924,18 @@ async function updateGroup( const previousId = conversation.get('groupId'); const idChanged = previousId && previousId !== newAttributes.groupId; - // We force this conversation into the left pane if this is the first time we've - // fetched data about it, and we were able to fetch its name. Nobody likes to see - // Unknown Group in the left pane. - let activeAt = null; - if (viaSync) { - activeAt = conversation.get('active_at') || null; - } else if ((isInitialDataFetch || justJoinedGroup) && newAttributes.name) { + // By updating activeAt we force this conversation into the left pane if this is the + // first time we've fetched data about it, and we were able to fetch its name. Nobody + // likes to see Unknown Group in the left pane. After first fetch, we rely on normal + // message activity (including group change messsages) to set the timestamp properly. + let activeAt = conversation.get('active_at') || null; + if ( + !viaFirstStorageSync && + isMemberOrPendingOrAwaitingApproval && + isInitialDataFetch && + newAttributes.name + ) { activeAt = initialSentAt; - } else { - activeAt = newAttributes.active_at; } // Save all synthetic messages describing group changes @@ -3003,7 +3013,7 @@ async function updateGroup( conversation.set({ ...newAttributes, active_at: activeAt, - temporaryMemberCount: isInGroup + temporaryMemberCount: !newAttributes.left ? undefined : newAttributes.temporaryMemberCount, }); @@ -3014,6 +3024,37 @@ async function updateGroup( // Save these most recent updates to conversation await updateConversation(conversation.attributes); + + // If we've been added by a blocked contact, then schedule a task to leave group + const justAdded = !wasMemberOrPending && isMemberOrPending; + const addedBy = + newAttributes.pendingMembersV2?.find(item => item.uuid === ourUuid) + ?.addedByUserId || newAttributes.addedBy; + + if (justAdded && addedBy) { + const adder = window.ConversationController.get(addedBy); + + if (adder && adder.isBlocked()) { + log.warn( + `updateGroup/${logId}: Added to group by blocked user ${adder.idForLogging()}. Scheduling group leave.` + ); + + // Wait for empty queue to make it more likely the group update succeeds + const waitThenLeave = async () => { + log.warn(`waitThenLeave/${logId}: Waiting for empty event queue.`); + await window.waitForEmptyEventQueue(); + log.warn( + `waitThenLeave/${logId}: Empty event queue, starting group leave.` + ); + + await conversation.leaveGroupV2(); + log.warn(`waitThenLeave/${logId}: Leave complete.`); + }; + + // Cannot await here, would infinitely block queue + waitThenLeave(); + } + } } // Exported for testing @@ -3295,7 +3336,6 @@ async function getGroupUpdates({ group, newRevision, groupChange, - serverPublicParamsBase64, }); } @@ -3309,13 +3349,10 @@ async function getGroupUpdates({ window.GV2_ENABLE_CHANGE_PROCESSING ) { try { - const result = await updateGroupViaLogs({ + return await updateGroupViaLogs({ group, - serverPublicParamsBase64, newRevision, }); - - return result; } catch (error) { const nextStep = isFirstFetch ? `fetching logs since ${newRevision}` @@ -3338,11 +3375,44 @@ async function getGroupUpdates({ } if (window.GV2_ENABLE_STATE_PROCESSING) { - return updateGroupViaState({ - dropInitialJoinMessage, - group, - serverPublicParamsBase64, - }); + try { + return await updateGroupViaState({ + dropInitialJoinMessage, + group, + }); + } catch (error) { + if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { + log.info( + `getGroupUpdates/${logId}: Temporal credential failure. Failing; we don't know if we have access or not.` + ); + throw error; + } else if (error.code === GROUP_ACCESS_DENIED_CODE) { + // We will fail over to the updateGroupViaPreJoinInfo call below + log.info( + `getGroupUpdates/${logId}: Failed to get group state. Attempting to fetch pre-join information.` + ); + } else { + throw error; + } + } + } + + if (window.GV2_ENABLE_PRE_JOIN_FETCH) { + try { + return await updateGroupViaPreJoinInfo({ + group, + }); + } catch (error) { + if (error.code === GROUP_ACCESS_DENIED_CODE) { + return generateLeftGroupChanges(group); + } + if (error.code === GROUP_NONEXISTENT_CODE) { + return generateLeftGroupChanges(group); + } + + // If we get another temporal failure, we'll fail and try again later. + throw error; + } } log.warn( @@ -3355,70 +3425,141 @@ async function getGroupUpdates({ }; } -async function updateGroupViaState({ - dropInitialJoinMessage, +async function updateGroupViaPreJoinInfo({ group, - serverPublicParamsBase64, }: { - dropInitialJoinMessage?: boolean; group: ConversationAttributesType; - serverPublicParamsBase64: string; }): Promise { const logId = idForLogging(group.groupId); const data = window.storage.get(GROUP_CREDENTIALS_KEY); if (!data) { - throw new Error('updateGroupViaState: No group credentials!'); + throw new Error('updateGroupViaPreJoinInfo: No group credentials!'); + } + const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); + + const { publicParams, secretParams } = group; + if (!secretParams) { + throw new Error( + 'updateGroupViaPreJoinInfo: group was missing secretParams!' + ); + } + if (!publicParams) { + throw new Error( + 'updateGroupViaPreJoinInfo: group was missing publicParams!' + ); } - const groupCredentials = getCredentialsForToday(data); + // No password, but if we're already pending approval, we can access this without it. + const inviteLinkPassword = undefined; + const preJoinInfo = await makeRequestWithTemporalRetry({ + logId: `getPreJoinInfo/${logId}`, + publicParams, + secretParams, + request: (sender, options) => + sender.getGroupFromLink(inviteLinkPassword, options), + }); - const stateOptions = { - dropInitialJoinMessage, - group, - serverPublicParamsBase64, - authCredentialBase64: groupCredentials.today.credential, + const approvalRequired = + preJoinInfo.addFromInviteLink === + Proto.AccessControl.AccessRequired.ADMINISTRATOR; + + // If the group doesn't require approval to join via link, then we should never have + // gotten here. + if (!approvalRequired) { + return generateLeftGroupChanges(group); + } + + const newAttributes: ConversationAttributesType = { + ...group, + description: decryptGroupDescription( + preJoinInfo.descriptionBytes, + secretParams + ), + name: decryptGroupTitle(preJoinInfo.title, secretParams), + members: [], + pendingMembersV2: [], + pendingAdminApprovalV2: [ + { + uuid: ourUuid, + timestamp: Date.now(), + }, + ], + revision: preJoinInfo.version, + + temporaryMemberCount: preJoinInfo.memberCount || 1, }; - try { - log.info(`updateGroupViaState/${logId}: Getting full group state...`); - // We await this here so our try/catch below takes effect - const result = await getCurrentGroupState(stateOptions); - return result; - } catch (error) { - if (error.code === GROUP_ACCESS_DENIED_CODE) { - return generateLeftGroupChanges(group); - } - if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { - log.info( - `updateGroupViaState/${logId}: Credential for today failed, failing over to tomorrow...` - ); - try { - const result = await getCurrentGroupState({ - ...stateOptions, - authCredentialBase64: groupCredentials.tomorrow.credential, - }); - return result; - } catch (subError) { - if (subError.code === GROUP_ACCESS_DENIED_CODE) { - return generateLeftGroupChanges(group); - } - } - } + await applyNewAvatar(dropNull(preJoinInfo.avatar), newAttributes, logId); - throw error; + return { + newAttributes, + groupChangeMessages: extractDiffs({ + old: group, + current: newAttributes, + dropInitialJoinMessage: false, + }), + members: [], + }; +} + +async function updateGroupViaState({ + dropInitialJoinMessage, + group, +}: { + dropInitialJoinMessage?: boolean; + group: ConversationAttributesType; +}): Promise { + const logId = idForLogging(group.groupId); + const { publicParams, secretParams } = group; + if (!secretParams) { + throw new Error('updateGroupViaState: group was missing secretParams!'); } + if (!publicParams) { + throw new Error('updateGroupViaState: group was missing publicParams!'); + } + + const groupState = await makeRequestWithTemporalRetry({ + logId: `getGroup/${logId}`, + publicParams, + secretParams, + request: (sender, requestOptions) => sender.getGroup(requestOptions), + }); + + const decryptedGroupState = decryptGroupState( + groupState, + secretParams, + logId + ); + + const oldVersion = group.revision; + const newVersion = decryptedGroupState.version; + log.info( + `getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.` + ); + const { newAttributes, newProfileKeys } = await applyGroupState({ + group, + groupState: decryptedGroupState, + }); + + return { + newAttributes, + groupChangeMessages: extractDiffs({ + old: group, + current: newAttributes, + dropInitialJoinMessage, + }), + members: profileKeysToMembers(newProfileKeys), + }; } async function updateGroupViaSingleChange({ group, groupChange, newRevision, - serverPublicParamsBase64, }: { group: ConversationAttributesType; groupChange: Proto.IGroupChange; newRevision: number; - serverPublicParamsBase64: string; }): Promise { const wasInGroup = !group.left; const result: UpdatesResultType = await integrateGroupChange({ @@ -3434,7 +3575,6 @@ async function updateGroupViaSingleChange({ if (!wasInGroup && nowInGroup) { const { newAttributes, members } = await updateGroupViaState({ group: result.newAttributes, - serverPublicParamsBase64, }); // We discard any change events that come out of this full group fetch, but we do @@ -3451,48 +3591,73 @@ async function updateGroupViaSingleChange({ async function updateGroupViaLogs({ group, - serverPublicParamsBase64, newRevision, }: { group: ConversationAttributesType; newRevision: number | undefined; - serverPublicParamsBase64: string; }): Promise { const logId = idForLogging(group.groupId); - const data = window.storage.get(GROUP_CREDENTIALS_KEY); - if (!data) { - throw new Error('getGroupUpdates: No group credentials!'); + const { publicParams, secretParams } = group; + if (!publicParams) { + throw new Error('updateGroupViaLogs: group was missing publicParams!'); + } + if (!secretParams) { + throw new Error('updateGroupViaLogs: group was missing secretParams!'); } - const groupCredentials = getCredentialsForToday(data); - const deltaOptions = { + log.info( + `updateGroupViaLogs/${logId}: Getting group delta from ` + + `${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` + + `groupv2(${group.groupId})...` + ); + + const currentRevision = group.revision; + let includeFirstState = true; + + // The range is inclusive so make sure that we always request the revision + // that we are currently at since we might want the latest full state in + // `integrateGroupChanges`. + let revisionToFetch = isNumber(currentRevision) ? currentRevision : undefined; + + let response; + const changes: Array = []; + do { + // eslint-disable-next-line no-await-in-loop + response = await makeRequestWithTemporalRetry({ + logId: `getGroupLog/${logId}`, + publicParams, + secretParams, + // eslint-disable-next-line no-loop-func + request: (sender, requestOptions) => + sender.getGroupLog( + { + startVersion: revisionToFetch, + includeFirstState, + includeLastState: true, + maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH, + }, + requestOptions + ), + }); + + changes.push(response.changes); + if (response.end) { + revisionToFetch = response.end + 1; + } + + includeFirstState = false; + } while ( + response.end && + (newRevision === undefined || response.end < newRevision) + ); + + // Would be nice to cache the unused groupChanges here, to reduce server roundtrips + + return integrateGroupChanges({ + changes, group, newRevision, - serverPublicParamsBase64, - authCredentialBase64: groupCredentials.today.credential, - }; - try { - log.info( - `updateGroupViaLogs/${logId}: Getting group delta from ` + - `${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` + - `groupv2(${group.groupId})...` - ); - const result = await getGroupDelta(deltaOptions); - - return result; - } catch (error) { - if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { - log.info( - `updateGroupViaLogs/${logId}: Credential for today failed, failing over to tomorrow...` - ); - - return getGroupDelta({ - ...deltaOptions, - authCredentialBase64: groupCredentials.tomorrow.credential, - }); - } - throw error; - } + }); } async function generateLeftGroupChanges( @@ -3524,34 +3689,28 @@ async function generateLeftGroupChanges( ); } - const existingMembers = group.membersV2 || []; const newAttributes: ConversationAttributesType = { ...group, - membersV2: existingMembers.filter(member => member.uuid !== ourUuid), + addedBy: undefined, + membersV2: (group.membersV2 || []).filter( + member => member.uuid !== ourUuid + ), + pendingMembersV2: (group.pendingMembersV2 || []).filter( + member => member.uuid !== ourUuid + ), + pendingAdminApprovalV2: (group.pendingAdminApprovalV2 || []).filter( + member => member.uuid !== ourUuid + ), left: true, revision, }; - const isNewlyRemoved = - existingMembers.length > (newAttributes.membersV2 || []).length; - - const youWereRemovedMessage: GroupChangeMessageType = { - ...generateBasicMessage(), - type: 'group-v2-change', - groupV2Change: { - details: [ - { - type: 'member-remove' as const, - uuid: ourUuid, - }, - ], - }, - readStatus: ReadStatus.Read, - seenStatus: SeenStatus.Unseen, - }; return { newAttributes, - groupChangeMessages: isNewlyRemoved ? [youWereRemovedMessage] : [], + groupChangeMessages: extractDiffs({ + current: newAttributes, + old: group, + }), members: [], }; } @@ -3583,76 +3742,6 @@ function getGroupCredentials({ }; } -async function getGroupDelta({ - group, - newRevision, - serverPublicParamsBase64, - authCredentialBase64, -}: { - group: ConversationAttributesType; - newRevision: number | undefined; - serverPublicParamsBase64: string; - authCredentialBase64: string; -}): Promise { - const sender = window.textsecure.messaging; - if (!sender) { - throw new Error('getGroupDelta: textsecure.messaging is not available!'); - } - if (!group.publicParams) { - throw new Error('getGroupDelta: group was missing publicParams!'); - } - if (!group.secretParams) { - throw new Error('getGroupDelta: group was missing secretParams!'); - } - - const options = getGroupCredentials({ - authCredentialBase64, - groupPublicParamsBase64: group.publicParams, - groupSecretParamsBase64: group.secretParams, - serverPublicParamsBase64, - }); - - const currentRevision = group.revision; - let includeFirstState = true; - - // The range is inclusive so make sure that we always request the revision - // that we are currently at since we might want the latest full state in - // `integrateGroupChanges`. - let revisionToFetch = isNumber(currentRevision) ? currentRevision : undefined; - - let response; - const changes: Array = []; - do { - // eslint-disable-next-line no-await-in-loop - response = await sender.getGroupLog( - { - startVersion: revisionToFetch, - includeFirstState, - includeLastState: true, - maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH, - }, - options - ); - changes.push(response.changes); - if (response.end) { - revisionToFetch = response.end + 1; - } - - includeFirstState = false; - } while ( - response.end && - (newRevision === undefined || response.end < newRevision) - ); - - // Would be nice to cache the unused groupChanges here, to reduce server roundtrips - - return integrateGroupChanges({ - changes, - group, - newRevision, - }); -} - async function integrateGroupChanges({ group, newRevision, @@ -3840,7 +3929,10 @@ async function integrateGroupChange({ } if (groupChangeActions.version === group.revision) { isSameVersion = true; - } else if (groupChangeActions.version > group.revision + 1) { + } else if ( + groupChangeActions.version > group.revision + 1 || + (!isNumber(group.revision) && groupChangeActions.version > 0) + ) { isMoreThanOneVersionUp = true; } } @@ -3956,64 +4048,6 @@ async function integrateGroupChange({ }; } -async function getCurrentGroupState({ - authCredentialBase64, - dropInitialJoinMessage, - group, - serverPublicParamsBase64, -}: { - authCredentialBase64: string; - dropInitialJoinMessage?: boolean; - group: ConversationAttributesType; - serverPublicParamsBase64: string; -}): Promise { - const logId = idForLogging(group.groupId); - const sender = window.textsecure.messaging; - if (!sender) { - throw new Error('textsecure.messaging is not available!'); - } - if (!group.secretParams) { - throw new Error('getCurrentGroupState: group was missing secretParams!'); - } - if (!group.publicParams) { - throw new Error('getCurrentGroupState: group was missing publicParams!'); - } - - const options = getGroupCredentials({ - authCredentialBase64, - groupPublicParamsBase64: group.publicParams, - groupSecretParamsBase64: group.secretParams, - serverPublicParamsBase64, - }); - - const groupState = await sender.getGroup(options); - const decryptedGroupState = decryptGroupState( - groupState, - group.secretParams, - logId - ); - - const oldVersion = group.revision; - const newVersion = decryptedGroupState.version; - log.info( - `getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.` - ); - const { newAttributes, newProfileKeys } = await applyGroupState({ - group, - groupState: decryptedGroupState, - }); - - return { - newAttributes, - groupChangeMessages: extractDiffs({ - old: group, - current: newAttributes, - dropInitialJoinMessage, - }), - members: profileKeysToMembers(newProfileKeys), - }; -} - function extractDiffs({ current, dropInitialJoinMessage, @@ -4032,6 +4066,7 @@ function extractDiffs({ let areWeInGroup = false; let areWeInvitedToGroup = false; + let areWePendingApproval = false; let whoInvitedUsUserId = null; // access control @@ -4286,6 +4321,10 @@ function extractDiffs({ const { uuid } = currentPendingAdminAprovalMember; const oldPendingMember = oldPendingAdminApprovalLookup.get(uuid); + if (uuid === ourUuid) { + areWePendingApproval = true; + } + if (!oldPendingMember) { details.push({ type: 'admin-approval-add-one', @@ -4349,10 +4388,29 @@ function extractDiffs({ readStatus: ReadStatus.Read, seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen, }; + } else if (firstUpdate && areWePendingApproval) { + message = { + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + from: ourUuid, + details: [ + { + type: 'admin-approval-add-one', + uuid: ourUuid, + }, + ], + }, + }; } else if (firstUpdate && dropInitialJoinMessage) { // None of the rest of the messages should be added if dropInitialJoinMessage = true message = undefined; - } else if (firstUpdate && sourceUuid && sourceUuid === ourUuid) { + } else if ( + firstUpdate && + current.revision === 0 && + sourceUuid && + sourceUuid === ourUuid + ) { message = { ...generateBasicMessage(), type: 'group-v2-change', @@ -4383,7 +4441,7 @@ function extractDiffs({ readStatus: ReadStatus.Read, seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen, }; - } else if (firstUpdate) { + } else if (firstUpdate && current.revision === 0) { message = { ...generateBasicMessage(), type: 'group-v2-change', @@ -4944,6 +5002,9 @@ async function applyGroupChange({ if (ourUuid) { result.left = !members[ourUuid]; } + if (result.left) { + result.addedBy = undefined; + } // Go from lookups back to arrays result.membersV2 = values(members); @@ -5092,6 +5153,9 @@ async function applyGroupState({ const ourUuid = window.storage.user.getCheckedUuid().toString(); // members + const wasPreviouslyAMember = (result.membersV2 || []).some( + item => item.uuid !== ourUuid + ); if (groupState.members) { result.membersV2 = groupState.members.map(member => { if (member.userId === ourUuid) { @@ -5100,7 +5164,9 @@ async function applyGroupState({ // Capture who added us if we were previously not in group if ( sourceUuid && - (result.membersV2 || []).every(item => item.uuid !== ourUuid) + !wasPreviouslyAMember && + isNumber(member.joinedAtVersion) && + member.joinedAtVersion === version ) { result.addedBy = sourceUuid; } @@ -5207,6 +5273,10 @@ async function applyGroupState({ // membersBanned result.bannedMembersV2 = groupState.membersBanned; + if (result.left) { + result.addedBy = undefined; + } + return { newAttributes: result, newProfileKeys, diff --git a/ts/groups/joinViaLink.ts b/ts/groups/joinViaLink.ts index ff4bcf652..e5ad473af 100644 --- a/ts/groups/joinViaLink.ts +++ b/ts/groups/joinViaLink.ts @@ -27,6 +27,7 @@ import { ToastAlreadyGroupMember } from '../components/ToastAlreadyGroupMember'; import { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin'; import { HTTPError } from '../textsecure/Errors'; import { isAccessControlEnabled } from './util'; +import { sleep } from '../util/sleep'; export async function joinViaLink(hash: string): Promise { let inviteLinkPassword: string; @@ -153,6 +154,15 @@ export async function joinViaLink(hash: string): Promise { log.warn( `joinViaLink/${logId}: Already awaiting approval, opening conversation` ); + const timestamp = existingConversation.get('timestamp') || Date.now(); + // eslint-disable-next-line camelcase + const active_at = existingConversation.get('active_at') || Date.now(); + existingConversation.set({ active_at, timestamp }); + window.Signal.Data.updateConversation(existingConversation.attributes); + + // We're waiting for the left pane to re-sort before we navigate to that conversation + await sleep(200); + window.reduxActions.conversations.openConversationInternal({ conversationId: existingConversation.id, }); @@ -257,6 +267,9 @@ export async function joinViaLink(hash: string): Promise { // This will cause this conversation to be deleted at next startup isTemporary: true, + active_at: Date.now(), + timestamp: Date.now(), + groupVersion: 2, masterKey, secretParams, @@ -272,6 +285,7 @@ export async function joinViaLink(hash: string): Promise { path: localAvatar.path, } : undefined, + description: groupDescription, groupInviteLinkPassword: inviteLinkPassword, name: title, temporaryMemberCount: memberCount, @@ -281,7 +295,13 @@ export async function joinViaLink(hash: string): Promise { } else { // Ensure the group maintains the title and avatar you saw when attempting // to join it. + const timestamp = + targetConversation.get('timestamp') || Date.now(); + // eslint-disable-next-line camelcase + const active_at = + targetConversation.get('active_at') || Date.now(); targetConversation.set({ + active_at, avatar: localAvatar && localAvatar.path && result.avatar ? { @@ -289,9 +309,12 @@ export async function joinViaLink(hash: string): Promise { path: localAvatar.path, } : undefined, + description: groupDescription, groupInviteLinkPassword: inviteLinkPassword, name: title, + revision: result.version, temporaryMemberCount: memberCount, + timestamp, }); window.Signal.Data.updateConversation( targetConversation.attributes diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b93be7436..05d2558a4 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2277,7 +2277,9 @@ export class ConversationModel extends window.Backbone const inviteLinkPassword = this.get('groupInviteLinkPassword'); if (!inviteLinkPassword) { - throw new Error('Missing groupInviteLinkPassword!'); + log.warn( + `cancelJoinRequest/${this.idForLogging()}: We don't have an inviteLinkPassword!` + ); } await this.modifyGroupV2({ diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 038a977d0..de2b7d95e 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2019,9 +2019,10 @@ export class MessageModel extends window.Backbone.Model { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversation = window.ConversationController.get(conversationId)!; + const idLog = conversation.idForLogging(); await conversation.queueJob('handleDataMessage', async () => { log.info( - `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` + `handleDataMessage/${idLog}: processsing message ${message.idForLogging()}` ); if ( @@ -2031,7 +2032,7 @@ export class MessageModel extends window.Backbone.Model { }) ) { log.info( - 'handleDataMessage: dropping story from !accepted', + `handleDataMessage/${idLog}: dropping story from !accepted`, this.getSenderIdentifier() ); confirm(); @@ -2043,10 +2044,13 @@ export class MessageModel extends window.Backbone.Model { this.getSenderIdentifier() ); if (inMemoryMessage) { - log.info('handleDataMessage: cache hit', this.getSenderIdentifier()); + log.info( + `handleDataMessage/${idLog}: cache hit`, + this.getSenderIdentifier() + ); } else { log.info( - 'handleDataMessage: duplicate check db lookup needed', + `handleDataMessage/${idLog}: duplicate check db lookup needed`, this.getSenderIdentifier() ); } @@ -2055,14 +2059,17 @@ export class MessageModel extends window.Backbone.Model { const isUpdate = Boolean(data && data.isRecipientUpdate); if (existingMessage && type === 'incoming') { - log.warn('Received duplicate message', this.idForLogging()); + log.warn( + `handleDataMessage/${idLog}: Received duplicate message`, + this.idForLogging() + ); confirm(); return; } if (type === 'outgoing') { if (isUpdate && existingMessage) { log.info( - `handleDataMessage: Updating message ${message.idForLogging()} with received transcript` + `handleDataMessage/${idLog}: Updating message ${message.idForLogging()} with received transcript` ); const toUpdate = window.MessageController.register( @@ -2139,7 +2146,7 @@ export class MessageModel extends window.Backbone.Model { } if (isUpdate) { log.warn( - `handleDataMessage: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.` + `handleDataMessage/${idLog}: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.` ); confirm(); @@ -2147,7 +2154,7 @@ export class MessageModel extends window.Backbone.Model { } if (existingMessage) { log.warn( - `handleDataMessage: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` + `handleDataMessage/${idLog}: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` ); confirm(); @@ -2212,7 +2219,7 @@ export class MessageModel extends window.Backbone.Model { } catch (error) { const errorText = error && error.stack ? error.stack : error; log.error( - `handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` + `handleDataMessage/${idLog}: Failed to process group update as part of message ${message.idForLogging()}: ${errorText}` ); throw error; } @@ -2233,6 +2240,19 @@ export class MessageModel extends window.Backbone.Model { initialMessage.group && initialMessage.group.type !== Proto.GroupContext.Type.DELIVER; + // Drop if from blocked user. Only GroupV2 messages should need to be dropped here. + const isBlocked = + (source && window.storage.blocked.isBlocked(source)) || + (sourceUuid && window.storage.blocked.isUuidBlocked(sourceUuid)); + if (isBlocked) { + log.info( + `handleDataMessage/${idLog}: Dropping message from blocked sender. hasGroupV2Prop: ${hasGroupV2Prop}` + ); + + confirm(); + return; + } + // Drop an incoming GroupV2 message if we or the sender are not part of the group // after applying the message's associated group changes. if ( diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 0b50b3fac..6c599d072 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -744,7 +744,7 @@ export async function mergeGroupV2Record( conversation, dropInitialJoinMessage, }, - { viaSync: true } + { viaFirstStorageSync: isFirstSync } ); } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 631037156..9a79041d1 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1238,9 +1238,13 @@ export default class MessageReceiver // Note: we need to process this as part of decryption, because we might need this // sender key to decrypt the next message in the queue! + let isGroupV2 = false; + try { const content = Proto.Content.decode(plaintext); + isGroupV2 = Boolean(content.dataMessage?.groupV2); + if ( content.senderKeyDistributionMessage && Bytes.isNotEmpty(content.senderKeyDistributionMessage) @@ -1258,12 +1262,14 @@ export default class MessageReceiver ); } + // We want to process GroupV2 updates, even from blocked users. We'll drop them later. if ( - (envelope.source && this.isBlocked(envelope.source)) || - (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) + !isGroupV2 && + ((envelope.source && this.isBlocked(envelope.source)) || + (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid))) ) { log.info( - 'MessageReceiver.decryptEnvelope: Dropping message from blocked sender' + 'MessageReceiver.decryptEnvelope: Dropping non-GV2 message from blocked sender' ); return { plaintext: undefined, envelope }; } diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 12c3796f7..a9b90778d 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -2244,7 +2244,7 @@ export default class MessageSender { } async getGroupFromLink( - groupInviteLink: string, + groupInviteLink: string | undefined, auth: Readonly ): Promise { return this.server.getGroupFromLink(groupInviteLink, auth); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 5c3912296..14a8f2905 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -542,7 +542,7 @@ const URL_CALLS = { groupLog: 'v1/groups/logs', groupJoinedAtVersion: 'v1/groups/joined_at_version', groups: 'v1/groups', - groupsViaLink: 'v1/groups/join', + groupsViaLink: 'v1/groups/join/', groupToken: 'v1/groups/token', keys: 'v2/keys', messages: 'v1/messages', @@ -830,7 +830,7 @@ export type WebAPIType = { getHasSubscription: (subscriberId: Uint8Array) => Promise; getGroup: (options: GroupCredentialsType) => Promise; getGroupFromLink: ( - inviteLinkPassword: string, + inviteLinkPassword: string | undefined, auth: GroupCredentialsType ) => Promise; getGroupAvatar: (key: string) => Promise; @@ -2595,14 +2595,16 @@ export function initialize({ } async function getGroupFromLink( - inviteLinkPassword: string, + inviteLinkPassword: string | undefined, auth: GroupCredentialsType ): Promise { const basicAuth = generateGroupAuth( auth.groupPublicParamsHex, auth.authCredentialPresentationHex ); - const safeInviteLinkPassword = toWebSafeBase64(inviteLinkPassword); + const safeInviteLinkPassword = inviteLinkPassword + ? toWebSafeBase64(inviteLinkPassword) + : undefined; const response = await _ajax({ basicAuth, @@ -2611,7 +2613,9 @@ export function initialize({ host: storageUrl, httpType: 'GET', responseType: 'bytes', - urlParameters: `/${safeInviteLinkPassword}`, + urlParameters: safeInviteLinkPassword + ? `${safeInviteLinkPassword}` + : undefined, redactUrl: _createRedactor(safeInviteLinkPassword), }); diff --git a/ts/window.d.ts b/ts/window.d.ts index abf6e4e14..8b2396ba3 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -473,6 +473,7 @@ declare global { GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean; GV2_ENABLE_CHANGE_PROCESSING: boolean; GV2_ENABLE_STATE_PROCESSING: boolean; + GV2_ENABLE_PRE_JOIN_FETCH: boolean; GV2_MIGRATION_DISABLE_ADD: boolean; GV2_MIGRATION_DISABLE_INVITE: boolean;