diff --git a/ts/jobs/conversationJobQueue.ts b/ts/jobs/conversationJobQueue.ts index 35b7bba51..848136d0c 100644 --- a/ts/jobs/conversationJobQueue.ts +++ b/ts/jobs/conversationJobQueue.ts @@ -16,6 +16,7 @@ import { sendNormalMessage } from './helpers/sendNormalMessage'; import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate'; import { sendGroupUpdate } from './helpers/sendGroupUpdate'; import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone'; +import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone'; import { sendProfileKey } from './helpers/sendProfileKey'; import { sendReaction } from './helpers/sendReaction'; import { sendStory } from './helpers/sendStory'; @@ -41,6 +42,7 @@ import type { UUIDStringType } from '../types/UUID'; // these values, you'll likely need to write a database migration. export const conversationQueueJobEnum = z.enum([ 'DeleteForEveryone', + 'DeleteStoryForEveryone', 'DirectExpirationTimerUpdate', 'GroupUpdate', 'NormalMessage', @@ -61,6 +63,25 @@ export type DeleteForEveryoneJobData = z.infer< typeof deleteForEveryoneJobDataSchema >; +const deleteStoryForEveryoneJobDataSchema = z.object({ + type: z.literal(conversationQueueJobEnum.enum.DeleteStoryForEveryone), + conversationId: z.string(), + storyId: z.string(), + targetTimestamp: z.number(), + updatedStoryRecipients: z + .array( + z.object({ + destinationUuid: z.string(), + distributionListIds: z.array(z.string()), + isAllowedToReply: z.boolean(), + }) + ) + .optional(), +}); +export type DeleteStoryForEveryoneJobData = z.infer< + typeof deleteStoryForEveryoneJobDataSchema +>; + const expirationTimerUpdateJobDataSchema = z.object({ type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate), conversationId: z.string(), @@ -120,6 +141,7 @@ export type StoryJobData = z.infer; export const conversationQueueJobDataSchema = z.union([ deleteForEveryoneJobDataSchema, + deleteStoryForEveryoneJobDataSchema, expirationTimerUpdateJobDataSchema, groupUpdateJobDataSchema, normalMessageSendJobDataSchema, @@ -334,6 +356,9 @@ export class ConversationJobQueue extends JobQueue { case jobSet.DeleteForEveryone: await sendDeleteForEveryone(conversation, jobBundle, data); break; + case jobSet.DeleteStoryForEveryone: + await sendDeleteStoryForEveryone(conversation, jobBundle, data); + break; case jobSet.DirectExpirationTimerUpdate: await sendDirectExpirationTimerUpdate(conversation, jobBundle, data); break; diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts index dc5f84adf..a3ce145e4 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -55,15 +55,22 @@ export async function sendDeleteForEveryone( targetTimestamp, } = data; + const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`; + const message = await getMessageById(messageId); if (!message) { - log.error(`Failed to fetch message ${messageId}. Failing job.`); + log.error(`${logId}: Failed to fetch message. Failing job.`); return; } + const story = isStory(message.attributes); + if (story && !isGroupV2(conversation.attributes)) { + log.error(`${logId}: 1-on-1 Story DOE must use its own job. Failing job`); + return; + } if (!shouldContinue) { - log.info('Ran out of time. Giving up on sending delete for everyone'); + log.info(`${logId}: Ran out of time. Giving up on sending`); updateMessageWithFailure(message, [new Error('Ran out of time!')], log); return; } @@ -73,8 +80,6 @@ export async function sendDeleteForEveryone( const contentHint = ContentHint.RESENDABLE; const messageIds = [messageId]; - const logId = `deleteForEveryone/${conversation.idForLogging()}`; - const deletedForEveryoneSendStatus = message.get( 'deletedForEveryoneSendStatus' ); @@ -97,9 +102,8 @@ export async function sendDeleteForEveryone( 'conversationQueue/sendDeleteForEveryone', async abortSignal => { log.info( - `Sending deleteForEveryone to conversation ${logId}`, - `with timestamp ${timestamp}`, - `for message ${targetTimestamp}` + `${logId}: Sending deleteForEveryone with timestamp ${timestamp}` + + `for message ${targetTimestamp}, isStory=${story}` ); let profileKey: Uint8Array | undefined; diff --git a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts new file mode 100644 index 000000000..2bed97b8c --- /dev/null +++ b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts @@ -0,0 +1,306 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as Errors from '../../types/errors'; +import { getSendOptions } from '../../util/getSendOptions'; +import { isDirectConversation, isMe } from '../../util/whatTypeOfConversation'; +import { SignalService as Proto } from '../../protobuf'; +import { + handleMultipleSendErrors, + maybeExpandErrors, +} from './handleMultipleSendErrors'; +import { ourProfileKeyService } from '../../services/ourProfileKey'; + +import type { ConversationModel } from '../../models/conversations'; +import type { + ConversationQueueJobBundle, + DeleteStoryForEveryoneJobData, +} from '../conversationJobQueue'; +import { getUntrustedConversationUuids } from './getUntrustedConversationUuids'; +import { handleMessageSend } from '../../util/handleMessageSend'; +import { isConversationAccepted } from '../../util/isConversationAccepted'; +import { isConversationUnregistered } from '../../util/isConversationUnregistered'; +import { getMessageById } from '../../messages/getMessageById'; +import { isNotNil } from '../../util/isNotNil'; +import type { CallbackResultType } from '../../textsecure/Types.d'; +import type { MessageModel } from '../../models/messages'; +import { SendMessageProtoError } from '../../textsecure/Errors'; +import { strictAssert } from '../../util/assert'; +import type { LoggerType } from '../../types/Logging'; +import { isStory } from '../../messages/helpers'; + +export async function sendDeleteStoryForEveryone( + ourConversation: ConversationModel, + { + isFinalAttempt, + messaging, + shouldContinue, + timestamp, + timeRemaining, + log, + }: ConversationQueueJobBundle, + data: DeleteStoryForEveryoneJobData +): Promise { + const { storyId, targetTimestamp, updatedStoryRecipients } = data; + + const logId = `sendDeleteStoryForEveryone(${storyId})`; + + const message = await getMessageById(storyId); + if (!message) { + log.error(`${logId}: Failed to fetch message. Failing job.`); + return; + } + + if (!shouldContinue) { + log.info(`${logId}: Ran out of time. Giving up on sending`); + updateMessageWithFailure(message, [new Error('Ran out of time!')], log); + return; + } + + strictAssert( + isMe(ourConversation.attributes), + 'Story DOE must be sent on our conversaton' + ); + strictAssert(isStory(message.attributes), 'Story message must be a story'); + + const sendType = 'deleteForEveryone'; + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + const contentHint = ContentHint.RESENDABLE; + + const deletedForEveryoneSendStatus = message.get( + 'deletedForEveryoneSendStatus' + ); + strictAssert( + deletedForEveryoneSendStatus, + `${logId}: message does not have deletedForEveryoneSendStatus` + ); + const recipientIds = Object.entries(deletedForEveryoneSendStatus) + .filter(([_, isSent]) => !isSent) + .map(([conversationId]) => conversationId); + + const untrustedUuids = getUntrustedConversationUuids(recipientIds); + if (untrustedUuids.length) { + window.reduxActions.conversations.conversationStoppedByMissingVerification({ + conversationId: ourConversation.id, + untrustedUuids, + }); + throw new Error( + `Delete for everyone blocked because ${untrustedUuids.length} ` + + 'conversation(s) were untrusted. Failing this attempt.' + ); + } + + const recipientConversations = recipientIds + .map(conversationId => { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + log.error(`${logId}: conversation not found for ${conversationId}`); + return undefined; + } + if (!isDirectConversation(conversation.attributes)) { + log.error(`${logId}: conversation ${conversationId} is not direct`); + return undefined; + } + + if (!isConversationAccepted(conversation.attributes)) { + log.info( + `${logId}: conversation ${conversation.idForLogging()} ` + + 'is not accepted; refusing to send' + ); + updateMessageWithFailure( + message, + [new Error('Message request was not accepted')], + log + ); + return undefined; + } + if (isConversationUnregistered(conversation.attributes)) { + log.info( + `${logId}: conversation ${conversation.idForLogging()} ` + + 'is unregistered; refusing to send' + ); + updateMessageWithFailure( + message, + [new Error('Contact no longer has a Signal account')], + log + ); + return undefined; + } + if (conversation.isBlocked()) { + log.info( + `${logId}: conversation ${conversation.idForLogging()} ` + + 'is blocked; refusing to send' + ); + updateMessageWithFailure( + message, + [new Error('Contact is blocked')], + log + ); + return undefined; + } + + return conversation; + }) + .filter(isNotNil); + + const hadSuccessfulSends = doesMessageHaveSuccessfulSends(message); + let didSuccessfullySendOne = false; + + // Special case - we have no one to send it to so just send the sync message. + if (recipientConversations.length === 0) { + didSuccessfullySendOne = true; + } + + const profileKey = await ourProfileKeyService.get(); + + await Promise.all( + recipientConversations.map(conversation => { + return conversation.queueJob( + 'conversationQueue/sendStoryDeleteForEveryone', + async () => { + log.info( + `${logId}: Sending deleteStoryForEveryone with timestamp ${timestamp}` + ); + + const sendOptions = await getSendOptions(conversation.attributes, { + story: true, + }); + + try { + await handleMessageSend( + messaging.sendMessageToIdentifier({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + identifier: conversation.getSendTarget()!, + messageText: undefined, + attachments: [], + deletedForEveryoneTimestamp: targetTimestamp, + timestamp, + expireTimer: undefined, + contentHint, + groupId: undefined, + profileKey: conversation.get('profileSharing') + ? profileKey + : undefined, + options: sendOptions, + urgent: true, + story: true, + }), + { + messageIds: [storyId], + sendType, + } + ); + + didSuccessfullySendOne = true; + + await updateMessageWithSuccessfulSends(message, { + successfulIdentifiers: [conversation.id], + }); + } catch (error: unknown) { + if (error instanceof SendMessageProtoError) { + await updateMessageWithSuccessfulSends(message, error); + } + + const errors = maybeExpandErrors(error); + await handleMultipleSendErrors({ + errors, + isFinalAttempt, + log, + markFailed: () => updateMessageWithFailure(message, errors, log), + timeRemaining, + toThrow: error, + }); + } + } + ); + }) + ); + + // Send sync message exactly once per job. If any of the sends are successful + // and we didn't send the DOE itself before - it is a good time to send the + // sync message. + if (!hadSuccessfulSends && didSuccessfullySendOne) { + log.info(`${logId}: Sending sync message`); + const options = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); + + const destinationUuid = ourConversation + .getCheckedUuid('deleteStoryForEveryone') + .toString(); + + // Sync message for other devices + await handleMessageSend( + messaging.sendSyncMessage({ + destination: undefined, + destinationUuid, + storyMessageRecipients: updatedStoryRecipients, + expirationStartTimestamp: null, + isUpdate: true, + options, + timestamp: message.get('timestamp'), + urgent: false, + }), + { messageIds: [storyId], sendType } + ); + } +} + +function doesMessageHaveSuccessfulSends(message: MessageModel): boolean { + const map = message.get('deletedForEveryoneSendStatus') ?? {}; + + return Object.values(map).some(value => value === true); +} + +async function updateMessageWithSuccessfulSends( + message: MessageModel, + result?: CallbackResultType | SendMessageProtoError +): Promise { + if (!result) { + message.set({ + deletedForEveryoneSendStatus: {}, + deletedForEveryoneFailed: undefined, + }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + + return; + } + + const deletedForEveryoneSendStatus = { + ...message.get('deletedForEveryoneSendStatus'), + }; + + result.successfulIdentifiers?.forEach(identifier => { + const conversation = window.ConversationController.get(identifier); + if (!conversation) { + return; + } + deletedForEveryoneSendStatus[conversation.id] = true; + }); + + message.set({ + deletedForEveryoneSendStatus, + deletedForEveryoneFailed: undefined, + }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); +} + +async function updateMessageWithFailure( + message: MessageModel, + errors: ReadonlyArray, + log: LoggerType +): Promise { + log.error( + 'updateMessageWithFailure: Setting this set of errors', + errors.map(Errors.toLogFormat) + ); + + message.set({ deletedForEveryoneFailed: true }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); +} diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 883bc2cb2..288dac95c 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -13,7 +13,6 @@ import type { } from '../conversationJobQueue'; import type { LoggerType } from '../../types/Logging'; import type { MessageModel } from '../../models/messages'; -import type { SenderKeyInfoType } from '../../model-types.d'; import type { SendState, SendStateByConversationId, @@ -25,6 +24,7 @@ import { } from '../../messages/MessageSendState'; import type { UUIDStringType } from '../../types/UUID'; import * as Errors from '../../types/errors'; +import type { StoryMessageRecipientsType } from '../../types/Stories'; import dataInterface from '../../sql/Client'; import { SignalService as Proto } from '../../protobuf'; import { getMessagesById } from '../../messages/getMessagesById'; @@ -35,9 +35,9 @@ import { import { handleMessageSend } from '../../util/handleMessageSend'; import { handleMultipleSendErrors } from './handleMultipleSendErrors'; import { isGroupV2, isMe } from '../../util/whatTypeOfConversation'; -import { isNotNil } from '../../util/isNotNil'; import { ourProfileKeyService } from '../../services/ourProfileKey'; import { sendContentMessageToGroup } from '../../util/sendToGroup'; +import { distributionListToSendTarget } from '../../util/distributionListToSendTarget'; import { SendMessageChallengeError } from '../../textsecure/Errors'; export async function sendStory( @@ -283,8 +283,6 @@ export async function sendStory( const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - const recipientsSet = new Set(pendingSendRecipientIds); - const sendOptions = await getSendOptionsForRecipients( pendingSendRecipientIds, { story: true } @@ -303,28 +301,11 @@ export async function sendStory( isGroupV2(conversation.attributes) || Boolean(distributionList?.allowsReplies); - let inMemorySenderKeyInfo = distributionList?.senderKeyInfo; - const sendTarget = distributionList - ? { - getGroupId: () => undefined, - getMembers: () => - pendingSendRecipientIds - .map(uuid => window.ConversationController.get(uuid)) - .filter(isNotNil), - hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid), - idForLogging: () => `dl(${receiverId})`, - isGroupV2: () => true, - isValid: () => true, - getSenderKeyInfo: () => inMemorySenderKeyInfo, - saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => { - inMemorySenderKeyInfo = senderKeyInfo; - await dataInterface.modifyStoryDistribution({ - ...distributionList, - senderKeyInfo, - }); - }, - } + ? distributionListToSendTarget( + distributionList, + pendingSendRecipientIds + ) : conversation.toSenderKeyTarget(); const contentMessage = new Proto.Content(); @@ -530,11 +511,7 @@ export async function sendStory( }); // Build up the sync message's storyMessageRecipients and send it - const storyMessageRecipients: Array<{ - destinationUuid: string; - distributionListIds: Array; - isAllowedToReply: boolean; - }> = []; + const storyMessageRecipients: StoryMessageRecipientsType = []; recipientsByUuid.forEach((distributionListIds, destinationUuid) => { storyMessageRecipients.push({ destinationUuid, diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 39073dd3b..4a17083d5 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -153,6 +153,7 @@ export type MessageAttributesType = { storyDistributionListId?: string; storyId?: string; storyReplyContext?: StoryReplyContextType; + storyRecipientsVersion?: number; supportedVersionAtReceive?: unknown; synced?: boolean; unidentifiedDeliveryReceived?: boolean; diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index 749d6d8dc..8c0830f3a 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -109,6 +109,7 @@ export function getStoryDataFromMessageAttributes( 'source', 'sourceUuid', 'storyDistributionListId', + 'storyRecipientsVersion', 'timestamp', 'type', ]), diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index a5c06e7d6..c417e8194 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -77,6 +77,7 @@ export type StoryDataType = { | 'storyDistributionListId' | 'timestamp' | 'type' + | 'storyRecipientsVersion' > & { // don't want the fields to be optional as in MessageAttributesType expireTimer: DurationInSeconds | undefined; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 181f735eb..708ba3f44 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1431,11 +1431,7 @@ export default class MessageSender { urgent: boolean; options?: SendOptionsType; storyMessage?: Proto.StoryMessage; - storyMessageRecipients?: Array<{ - destinationUuid: string; - distributionListIds: Array; - isAllowedToReply: boolean; - }>; + storyMessageRecipients?: ReadonlyArray; }>): Promise { const myUuid = window.textsecure.storage.user.getCheckedUuid(); @@ -1461,17 +1457,7 @@ export default class MessageSender { sentMessage.storyMessage = storyMessage; } if (storyMessageRecipients) { - sentMessage.storyMessageRecipients = storyMessageRecipients.map( - recipient => { - const storyMessageRecipient = - new Proto.SyncMessage.Sent.StoryMessageRecipient(); - storyMessageRecipient.destinationUuid = recipient.destinationUuid; - storyMessageRecipient.distributionListIds = - recipient.distributionListIds; - storyMessageRecipient.isAllowedToReply = recipient.isAllowedToReply; - return storyMessageRecipient; - } - ); + sentMessage.storyMessageRecipients = storyMessageRecipients.slice(); } if (isUpdate) { diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index 7bf30f78a..002f4570b 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -166,3 +166,9 @@ export enum ResolvedSendStatus { Sending = 'Sending', Sent = 'Sent', } + +export type StoryMessageRecipientsType = Array<{ + destinationUuid: string; + distributionListIds: Array; + isAllowedToReply: boolean; +}>; diff --git a/ts/types/UUID.ts b/ts/types/UUID.ts index be2872384..0527f436c 100644 --- a/ts/types/UUID.ts +++ b/ts/types/UUID.ts @@ -16,11 +16,21 @@ export enum UUIDKind { export const UUID_BYTE_SIZE = 16; -export const isValidUuid = (value: unknown): value is UUIDStringType => - typeof value === 'string' && - /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test( - value - ); +const UUID_REGEXP = + /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; + +export const isValidUuid = (value: unknown): value is UUIDStringType => { + if (typeof value !== 'string') { + return false; + } + + // Zero UUID is a valid uuid. + if (value === '00000000-0000-0000-0000-000000000000') { + return true; + } + + return UUID_REGEXP.test(value); +}; export class UUID { constructor(protected readonly value: string) { diff --git a/ts/util/deleteStoryForEveryone.ts b/ts/util/deleteStoryForEveryone.ts index d4d93508f..a7675dc61 100644 --- a/ts/util/deleteStoryForEveryone.ts +++ b/ts/util/deleteStoryForEveryone.ts @@ -2,12 +2,25 @@ // SPDX-License-Identifier: AGPL-3.0-only import { noop } from 'lodash'; + +import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import type { StoryDataType } from '../state/ducks/stories'; +import * as Errors from '../types/errors'; +import type { StoryMessageRecipientsType } from '../types/Stories'; +import * as log from '../logging/log'; import { DAY } from './durations'; import { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents'; -import { getSendOptions } from './getSendOptions'; +import { + conversationJobQueue, + conversationQueueJobEnum, +} from '../jobs/conversationJobQueue'; import { onStoryRecipientUpdate } from './onStoryRecipientUpdate'; import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage'; +import { isGroupV2 } from './whatTypeOfConversation'; +import { getMessageById } from '../messages/getMessageById'; +import { strictAssert } from './assert'; +import { repeat, zipObject } from './iterables'; +import { isOlderThan } from './timestamp'; export async function deleteStoryForEveryone( stories: ReadonlyArray, @@ -17,8 +30,31 @@ export async function deleteStoryForEveryone( return; } + // Group stories are deleted as regular messages. + const sourceConversation = window.ConversationController.get( + story.conversationId + ); + if (sourceConversation && isGroupV2(sourceConversation.attributes)) { + sendDeleteForEveryoneMessage(sourceConversation.attributes, { + deleteForEveryoneDuration: DAY, + id: story.messageId, + timestamp: story.timestamp, + }); + return; + } + + const logId = `deleteStoryForEveryone(${story.messageId})`; + const message = await getMessageById(story.messageId); + if (!message) { + throw new Error('Story not found'); + } + + if (isOlderThan(story.timestamp, DAY)) { + throw new Error('Cannot send DOE for a story older than one day'); + } + const conversationIds = new Set(Object.keys(story.sendStateByConversationId)); - const updatedStoryRecipients = new Map< + const newStoryRecipients = new Map< string, { distributionListIds: Set; @@ -32,6 +68,30 @@ export async function deleteStoryForEveryone( // Remove ourselves from the DOE. conversationIds.delete(ourConversation.id); + // `updatedStoryRecipients` is used to build `storyMessageRecipients` for + // a sync message. Put all affected destinationUuids early on so that if + // there are no other distribution lists for them - we'd still include an + // empty list. + Object.entries(story.sendStateByConversationId).forEach( + ([recipientId, sendState]) => { + if (recipientId === ourConversation.id) { + return; + } + + const destinationUuid = + window.ConversationController.get(recipientId)?.get('uuid'); + + if (!destinationUuid) { + return; + } + + newStoryRecipients.set(destinationUuid, { + distributionListIds: new Set(), + isAllowedToReply: sendState.isAllowedToReplyToStory !== false, + }); + } + ); + // Find stories that were sent to other distribution lists so that we don't // send a DOE request to the members of those lists. stories.forEach(item => { @@ -62,120 +122,95 @@ export async function deleteStoryForEveryone( return; } - const distributionListIds = - updatedStoryRecipients.get(destinationUuid)?.distributionListIds || - new Set(); - - // These are the remaining distribution list ids that the user has - // access to. - updatedStoryRecipients.set(destinationUuid, { - distributionListIds: item.storyDistributionListId - ? new Set([...distributionListIds, item.storyDistributionListId]) - : distributionListIds, - isAllowedToReply: - sendStateByConversationId[conversationId].isAllowedToReplyToStory !== - false, - }); - // Remove this conversationId so we don't send the DOE to those that // still have access. conversationIds.delete(conversationId); + + // Build remaining distribution list ids that the user still has + // access to. + if (item.storyDistributionListId === undefined) { + return; + } + + // Build complete list of new story recipients (not counting ones that + // are in the deleted story). + let recipient = newStoryRecipients.get(destinationUuid); + if (!recipient) { + const isAllowedToReply = + sendStateByConversationId[conversationId].isAllowedToReplyToStory; + recipient = { + distributionListIds: new Set(), + isAllowedToReply: isAllowedToReply !== false, + }; + + newStoryRecipients.set(destinationUuid, recipient); + } + + recipient.distributionListIds.add(item.storyDistributionListId); }); }); + // Include the sync message with the updated storyMessageRecipients list + const sender = window.textsecure.messaging; + strictAssert(sender, 'messaging has to be initialized'); + + const newStoryMessageRecipients: StoryMessageRecipientsType = []; + + newStoryRecipients.forEach((recipientData, destinationUuid) => { + newStoryMessageRecipients.push({ + destinationUuid, + distributionListIds: Array.from(recipientData.distributionListIds), + isAllowedToReply: recipientData.isAllowedToReply, + }); + }); + + const destinationUuid = ourConversation + .getCheckedUuid('deleteStoryForEveryone') + .toString(); + + log.info(`${logId}: sending DOE to ${conversationIds.size} conversations`); + + message.set({ + deletedForEveryoneSendStatus: zipObject(conversationIds, repeat(false)), + }); + // Send the DOE - conversationIds.forEach(cid => { - // Don't DOE yourself! - if (cid === ourConversation.id) { - return; - } + log.info(`${logId}: enqueing DeleteStoryForEveryone`); - const conversation = window.ConversationController.get(cid); + try { + const jobData: ConversationQueueJobData = { + type: conversationQueueJobEnum.enum.DeleteStoryForEveryone, + conversationId: ourConversation.id, + storyId: story.messageId, + targetTimestamp: story.timestamp, + updatedStoryRecipients: newStoryMessageRecipients, + }; + await conversationJobQueue.add(jobData, async jobToInsert => { + log.info(`${logId}: Deleting message with job ${jobToInsert.id}`); - if (!conversation) { - return; - } - - sendDeleteForEveryoneMessage(conversation.attributes, { - deleteForEveryoneDuration: DAY, - id: story.messageId, - timestamp: story.timestamp, - }); - }); - - // If it's the last story sent to a distribution list we don't have to send - // the sync message, but to be consistent let's build up the updated - // storyMessageRecipients and send the sync message. - if (!updatedStoryRecipients.size) { - Object.entries(story.sendStateByConversationId).forEach( - ([recipientId, sendState]) => { - if (recipientId === ourConversation.id) { - return; - } - - const destinationUuid = - window.ConversationController.get(recipientId)?.get('uuid'); - - if (!destinationUuid) { - return; - } - - updatedStoryRecipients.set(destinationUuid, { - distributionListIds: new Set(), - isAllowedToReply: sendState.isAllowedToReplyToStory !== false, - }); - } - ); - } - - // Send the sync message with the updated storyMessageRecipients list - const sender = window.textsecure.messaging; - if (sender) { - const options = await getSendOptions(ourConversation.attributes, { - syncMessage: true, - }); - - const storyMessageRecipients: Array<{ - destinationUuid: string; - distributionListIds: Array; - isAllowedToReply: boolean; - }> = []; - - updatedStoryRecipients.forEach((recipientData, destinationUuid) => { - storyMessageRecipients.push({ - destinationUuid, - distributionListIds: Array.from(recipientData.distributionListIds), - isAllowedToReply: recipientData.isAllowedToReply, + await window.Signal.Data.saveMessage(message.attributes, { + jobToInsert, + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); }); - - const destinationUuid = ourConversation.get('uuid'); - - if (!destinationUuid) { - return; - } - - // Sync message for other devices - sender.sendSyncMessage({ - destination: undefined, - destinationUuid, - storyMessageRecipients, - expirationStartTimestamp: null, - isUpdate: true, - options, - timestamp: story.timestamp, - urgent: false, - }); - - // Sync message for Desktop - const ev = new StoryRecipientUpdateEvent( - { - destinationUuid, - timestamp: story.timestamp, - storyMessageRecipients, - }, - noop + } catch (error) { + log.error( + `${logId}: Failed to queue delete for everyone`, + Errors.toLogFormat(error) ); - onStoryRecipientUpdate(ev); + throw error; } + + log.info(`${logId}: emulating sync message event`); + + // Emulate message for Desktop (this will call deleteForEveryone()) + const ev = new StoryRecipientUpdateEvent( + { + destinationUuid, + timestamp: story.timestamp, + storyMessageRecipients: newStoryMessageRecipients, + }, + noop + ); + onStoryRecipientUpdate(ev); } diff --git a/ts/util/distributionListToSendTarget.ts b/ts/util/distributionListToSendTarget.ts new file mode 100644 index 000000000..a343ac845 --- /dev/null +++ b/ts/util/distributionListToSendTarget.ts @@ -0,0 +1,38 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { UUIDStringType } from '../types/UUID'; +import type { SenderKeyInfoType } from '../model-types.d'; +import dataInterface from '../sql/Client'; +import type { StoryDistributionType } from '../sql/Interface'; +import type { SenderKeyTargetType } from './sendToGroup'; +import { isNotNil } from './isNotNil'; + +export function distributionListToSendTarget( + distributionList: StoryDistributionType, + pendingSendRecipientIds: ReadonlyArray +): SenderKeyTargetType { + let inMemorySenderKeyInfo = distributionList?.senderKeyInfo; + + const recipientsSet = new Set(pendingSendRecipientIds); + + return { + getGroupId: () => undefined, + getMembers: () => + pendingSendRecipientIds + .map(uuid => window.ConversationController.get(uuid)) + .filter(isNotNil), + hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid), + idForLogging: () => `dl(${distributionList.id})`, + isGroupV2: () => true, + isValid: () => true, + getSenderKeyInfo: () => inMemorySenderKeyInfo, + saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => { + inMemorySenderKeyInfo = senderKeyInfo; + await dataInterface.modifyStoryDistribution({ + ...distributionList, + senderKeyInfo, + }); + }, + }; +} diff --git a/ts/util/onStoryRecipientUpdate.ts b/ts/util/onStoryRecipientUpdate.ts index 0bd79d23f..dfe319126 100644 --- a/ts/util/onStoryRecipientUpdate.ts +++ b/ts/util/onStoryRecipientUpdate.ts @@ -8,10 +8,7 @@ import * as log from '../logging/log'; import { Deletes } from '../messageModifiers/Deletes'; import { SendStatus } from '../messages/MessageSendState'; import { deleteForEveryone } from './deleteForEveryone'; -import { - getConversationIdForLogging, - getMessageIdForLogging, -} from './idForLogging'; +import { getConversationIdForLogging } from './idForLogging'; import { isStory } from '../state/selectors/message'; import { normalizeUuid } from './normalizeUuid'; import { queueUpdateMessage } from './messageBatcher'; @@ -25,8 +22,10 @@ export async function onStoryRecipientUpdate( const conversation = window.ConversationController.get(destinationUuid); + const logId = `onStoryRecipientUpdate(${destinationUuid}, ${timestamp})`; + if (!conversation) { - log.info(`onStoryRecipientUpdate no conversation for ${destinationUuid}`); + log.info(`${logId}: no conversation`); return; } @@ -37,20 +36,16 @@ export async function onStoryRecipientUpdate( ); if (!targetConversation) { - log.info('onStoryRecipientUpdate !targetConversation', { - destinationUuid, - timestamp, - }); - + log.info(`${logId}: no targetConversation`); return; } - targetConversation.queueJob('onStoryRecipientUpdate', async () => { - log.info('onStoryRecipientUpdate updating', timestamp); + targetConversation.queueJob(logId, async () => { + log.info(`${logId}: updating`); // Build up some maps for fast/easy lookups const isAllowedToReply = new Map(); - const conversationIdToDistributionListIds = new Map>(); + const distributionListIdToConversationIds = new Map>(); data.storyMessageRecipients.forEach(item => { const convo = window.ConversationController.get(item.destinationUuid); @@ -58,14 +53,16 @@ export async function onStoryRecipientUpdate( return; } - conversationIdToDistributionListIds.set( - convo.id, - new Set( - item.distributionListIds.map(uuid => - normalizeUuid(uuid, 'onStoryRecipientUpdate.distributionListId') - ) - ) - ); + for (const rawUuid of item.distributionListIds) { + const uuid = normalizeUuid(rawUuid, `${logId}.distributionListId`); + + const existing = distributionListIdToConversationIds.get(uuid); + if (existing === undefined) { + distributionListIdToConversationIds.set(uuid, new Set([convo.id])); + } else { + existing.add(convo.id); + } + } isAllowedToReply.set(convo.id, item.isAllowedToReply !== false); }); @@ -87,55 +84,60 @@ export async function onStoryRecipientUpdate( return false; } + const newConversationIds = + distributionListIdToConversationIds.get(storyDistributionListId) ?? + new Set(); + const nextSendStateByConversationId = { ...sendStateByConversationId, }; - conversationIdToDistributionListIds.forEach( - (distributionListIds, conversationId) => { - const hasDistributionListId = distributionListIds.has( - storyDistributionListId - ); + // Find conversation ids present in the local send state, but missing + // in the remote state, and remove them from the local state. + for (const oldId of Object.keys(sendStateByConversationId)) { + if (!newConversationIds.has(oldId)) { + const recipient = window.ConversationController.get(oldId); - const recipient = window.ConversationController.get(conversationId); - const conversationIdForLogging = recipient + const recipientLogId = recipient ? getConversationIdForLogging(recipient.attributes) - : conversationId; + : oldId; - if ( - hasDistributionListId && - !sendStateByConversationId[conversationId] - ) { - log.info('onStoryRecipientUpdate adding', { - conversationId: conversationIdForLogging, - messageId: getMessageIdForLogging(item), - storyDistributionListId, - }); - nextSendStateByConversationId[conversationId] = { - isAllowedToReplyToStory: Boolean( - isAllowedToReply.get(conversationId) - ), - status: SendStatus.Sent, - updatedAt: now, - }; - } else if ( - sendStateByConversationId[conversationId] && - !hasDistributionListId - ) { - log.info('onStoryRecipientUpdate removing', { - conversationId: conversationIdForLogging, - messageId: getMessageIdForLogging(item), - storyDistributionListId, - }); - delete nextSendStateByConversationId[conversationId]; - } + log.info(`${logId}: removing`, { + recipient: recipientLogId, + messageId: item.id, + storyDistributionListId, + }); + delete nextSendStateByConversationId[oldId]; } - ); + } + + // Find conversation ids present in the remote send state, but missing in + // the local send state, and add them to the local state. + for (const newId of newConversationIds) { + if (sendStateByConversationId[newId] === undefined) { + const recipient = window.ConversationController.get(newId); + + const recipientLogId = recipient + ? getConversationIdForLogging(recipient.attributes) + : newId; + + log.info(`${logId}: adding`, { + recipient: recipientLogId, + messageId: item.id, + storyDistributionListId, + }); + nextSendStateByConversationId[newId] = { + isAllowedToReplyToStory: Boolean(isAllowedToReply.get(newId)), + status: SendStatus.Sent, + updatedAt: now, + }; + } + } if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) { - log.info( - 'onStoryRecipientUpdate: sendStateByConversationId does not need update' - ); + log.info(`${logId}: sendStateByConversationId does not need update`, { + messageId: item.id, + }); return true; } @@ -150,8 +152,8 @@ export async function onStoryRecipientUpdate( (sendStateConversationIds.size === 1 && sendStateConversationIds.has(ourConversationId)) ) { - log.info('onStoryRecipientUpdate DOE', { - messageId: getMessageIdForLogging(item), + log.info(`${logId} DOE`, { + messageId: item.id, storyDistributionListId, }); const delAttributes: DeleteAttributesType = { diff --git a/ts/util/sendDeleteForEveryoneMessage.ts b/ts/util/sendDeleteForEveryoneMessage.ts index aa9f1080e..f6b2ab06b 100644 --- a/ts/util/sendDeleteForEveryoneMessage.ts +++ b/ts/util/sendDeleteForEveryoneMessage.ts @@ -40,8 +40,7 @@ export async function sendDeleteForEveryoneMessage( if (!message) { throw new Error('sendDeleteForEveryoneMessage: Cannot find message!'); } - const messageModel = window.MessageController.register(messageId, message); - const idForLogging = getMessageIdForLogging(messageModel.attributes); + const idForLogging = getMessageIdForLogging(message.attributes); const timestamp = Date.now(); const maxDuration = deleteForEveryoneDuration || THREE_HOURS; @@ -49,7 +48,7 @@ export async function sendDeleteForEveryoneMessage( throw new Error(`Cannot send DOE for a message older than ${maxDuration}`); } - messageModel.set({ + message.set({ deletedForEveryoneSendStatus: zipObject( getRecipientConversationIds(conversationAttributes), repeat(false) @@ -79,7 +78,7 @@ export async function sendDeleteForEveryoneMessage( `sendDeleteForEveryoneMessage: Deleting message ${idForLogging} ` + `in conversation ${conversationIdForLogging} with job ${jobToInsert.id}` ); - await window.Signal.Data.saveMessage(messageModel.attributes, { + await window.Signal.Data.saveMessage(message.attributes, { jobToInsert, ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); @@ -97,5 +96,5 @@ export async function sendDeleteForEveryoneMessage( serverTimestamp: Date.now(), fromId: window.ConversationController.getOurConversationIdOrThrow(), }); - await deleteForEveryone(messageModel, deleteModel); + await deleteForEveryone(message, deleteModel); } diff --git a/ts/util/wrapWithSyncMessageSend.ts b/ts/util/wrapWithSyncMessageSend.ts index 62b24f23f..226dc3d9f 100644 --- a/ts/util/wrapWithSyncMessageSend.ts +++ b/ts/util/wrapWithSyncMessageSend.ts @@ -15,7 +15,7 @@ import { areAllErrorsUnregistered } from '../jobs/helpers/areAllErrorsUnregister export async function wrapWithSyncMessageSend({ conversation, - logId, + logId: parentLogId, messageIds, send, sendType, @@ -28,11 +28,10 @@ export async function wrapWithSyncMessageSend({ sendType: SendTypesType; timestamp: number; }): Promise { + const logId = `wrapWithSyncMessageSend(${parentLogId}, ${timestamp})`; const sender = window.textsecure.messaging; if (!sender) { - throw new Error( - `wrapWithSyncMessageSend/${logId}: textsecure.messaging is not available!` - ); + throw new Error(`${logId}: textsecure.messaging is not available!`); } let response: CallbackResultType | undefined; @@ -52,17 +51,13 @@ export async function wrapWithSyncMessageSend({ if (thrown instanceof Error) { error = thrown; } else { - log.error( - `wrapWithSyncMessageSend/${logId}: Thrown value was not an Error, returning early` - ); + log.error(`${logId}: Thrown value was not an Error, returning early`); throw error; } } if (!response && !error) { - throw new Error( - `wrapWithSyncMessageSend/${logId}: message send didn't return result or error!` - ); + throw new Error(`${logId}: message send didn't return result or error!`); } const dataMessage = @@ -71,11 +66,9 @@ export async function wrapWithSyncMessageSend({ if (didSuccessfullySendOne) { if (!dataMessage) { - log.error( - `wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!` - ); + log.error(`${logId}: dataMessage was not returned by send!`); } else { - log.info(`wrapWithSyncMessageSend/${logId}: Sending sync message...`); + log.info(`${logId}: Sending sync message... `); const ourConversation = window.ConversationController.getOurConversationOrThrow(); const options = await getSendOptions(ourConversation.attributes, { @@ -99,7 +92,8 @@ export async function wrapWithSyncMessageSend({ if (error instanceof Error) { if (areAllErrorsUnregistered(conversation.attributes, error)) { log.info( - `wrapWithSyncMessageSend/${logId}: Group send failures were all UnregisteredUserError, returning succcessfully.` + `${logId}: Group send failures were all UnregisteredUserError, ` + + 'returning succcessfully.' ); return; }