You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			388 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			388 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
| /* eslint-disable no-param-reassign */
 | |
| import { isEmpty, isFinite, noop, omit, toNumber } from 'lodash';
 | |
| 
 | |
| import { SignalService } from '../protobuf';
 | |
| import { removeFromCache } from './cache';
 | |
| import { EnvelopePlus } from './types';
 | |
| 
 | |
| import { Data } from '../data/data';
 | |
| import { ConversationModel } from '../models/conversation';
 | |
| import { getConversationController } from '../session/conversations';
 | |
| import { PubKey } from '../session/types';
 | |
| import { StringUtils, UserUtils } from '../session/utils';
 | |
| import { handleClosedGroupControlMessage } from './closedGroups';
 | |
| import { handleMessageJob, toRegularMessage } from './queuedJob';
 | |
| 
 | |
| import { ConversationTypeEnum } from '../models/conversationAttributes';
 | |
| import { MessageModel } from '../models/message';
 | |
| import {
 | |
|   createSwarmMessageSentFromNotUs,
 | |
|   createSwarmMessageSentFromUs,
 | |
| } from '../models/messageFactory';
 | |
| import { DisappearingMessages } from '../session/disappearing_messages';
 | |
| import { DisappearingMessageUpdate } from '../session/disappearing_messages/types';
 | |
| import { ProfileManager } from '../session/profile_manager/ProfileManager';
 | |
| import { isUsFromCache } from '../session/utils/User';
 | |
| import { Action, Reaction } from '../types/Reaction';
 | |
| import { toLogFormat } from '../types/attachments/Errors';
 | |
| import { Reactions } from '../util/reactions';
 | |
| 
 | |
| function cleanAttachment(attachment: any) {
 | |
|   return {
 | |
|     ...omit(attachment, 'thumbnail'),
 | |
|     id: attachment.id.toString(),
 | |
|     key: attachment.key ? StringUtils.decode(attachment.key, 'base64') : null,
 | |
|     digest:
 | |
|       attachment.digest && attachment.digest.length > 0
 | |
|         ? StringUtils.decode(attachment.digest, 'base64')
 | |
|         : null,
 | |
|   };
 | |
| }
 | |
| 
 | |
| function cleanAttachments(decrypted: SignalService.DataMessage) {
 | |
|   const { quote } = decrypted;
 | |
| 
 | |
|   // Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
 | |
| 
 | |
|   // we do not care about group on Session
 | |
| 
 | |
|   decrypted.group = null;
 | |
| 
 | |
|   // when receiving a message we get keys of attachment as buffer, but we override the data with the decrypted string instead.
 | |
|   // TODO it would be nice to get rid of that as any here, but not in this PR
 | |
|   decrypted.attachments = (decrypted.attachments || []).map(cleanAttachment) as any;
 | |
|   decrypted.preview = (decrypted.preview || []).map((item: any) => {
 | |
|     const { image } = item;
 | |
| 
 | |
|     if (!image) {
 | |
|       return item;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       ...item,
 | |
|       image: cleanAttachment(image),
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   if (quote) {
 | |
|     if (quote.id) {
 | |
|       quote.id = toNumber(quote.id);
 | |
|     }
 | |
| 
 | |
|     quote.attachments = (quote.attachments || []).map((item: any) => {
 | |
|       const { thumbnail } = item;
 | |
| 
 | |
|       if (!thumbnail || thumbnail.length === 0) {
 | |
|         return item;
 | |
|       }
 | |
| 
 | |
|       return {
 | |
|         ...item,
 | |
|         thumbnail: cleanAttachment(item.thumbnail),
 | |
|       };
 | |
|     });
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function messageHasVisibleContent(message: SignalService.DataMessage) {
 | |
|   const { flags, body, attachments, quote, preview, openGroupInvitation, reaction } = message;
 | |
| 
 | |
|   return (
 | |
|     !!flags ||
 | |
|     !isEmpty(body) ||
 | |
|     !isEmpty(attachments) ||
 | |
|     !isEmpty(quote) ||
 | |
|     !isEmpty(preview) ||
 | |
|     !isEmpty(openGroupInvitation) ||
 | |
|     !isEmpty(reaction)
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function cleanIncomingDataMessage(
 | |
|   rawDataMessage: SignalService.DataMessage,
 | |
|   envelope?: EnvelopePlus
 | |
| ) {
 | |
|   const FLAGS = SignalService.DataMessage.Flags;
 | |
| 
 | |
|   // Now that its decrypted, validate the message and clean it up for consumer
 | |
|   //   processing
 | |
|   // Note that messages may (generally) only perform one action and we ignore remaining
 | |
|   //   fields after the first action.
 | |
| 
 | |
|   if (rawDataMessage.flags == null) {
 | |
|     rawDataMessage.flags = 0;
 | |
|   }
 | |
|   // TODO legacy messages support will be removed in a future release
 | |
|   if (rawDataMessage.expireTimer == null) {
 | |
|     rawDataMessage.expireTimer = 0;
 | |
|   }
 | |
|   // eslint-disable-next-line no-bitwise
 | |
|   if (rawDataMessage.flags & FLAGS.EXPIRATION_TIMER_UPDATE) {
 | |
|     rawDataMessage.body = '';
 | |
|     rawDataMessage.attachments = [];
 | |
|   } else if (rawDataMessage.flags !== 0) {
 | |
|     throw new Error('Unknown flags in message');
 | |
|   }
 | |
| 
 | |
|   const attachmentCount = rawDataMessage?.attachments?.length || 0;
 | |
|   const ATTACHMENT_MAX = 32;
 | |
|   if (attachmentCount > ATTACHMENT_MAX) {
 | |
|     throw new Error(
 | |
|       `Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
 | |
|     );
 | |
|   }
 | |
|   cleanAttachments(rawDataMessage);
 | |
| 
 | |
|   // if the decrypted dataMessage timestamp is not set, copy the one from the envelope
 | |
|   if (!isFinite(rawDataMessage?.timestamp) && envelope) {
 | |
|     rawDataMessage.timestamp = envelope.timestamp;
 | |
|   }
 | |
| 
 | |
|   return rawDataMessage;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * We have a few origins possible
 | |
|  *    - if the message is from a private conversation with a friend and he wrote to us,
 | |
|  *        the conversation to add the message to is our friend pubkey, so envelope.source
 | |
|  *    - if the message is from a medium group conversation
 | |
|  *        * envelope.source is the medium group pubkey
 | |
|  *        * envelope.senderIdentity is the author pubkey (the one who sent the message)
 | |
|  *    - at last, if the message is a syncMessage,
 | |
|  *        * envelope.source is our pubkey (our other device has the same pubkey as us)
 | |
|  *        * dataMessage.syncTarget is either the group public key OR the private conversation this message is about.
 | |
|  */
 | |
| 
 | |
| export async function handleSwarmDataMessage({
 | |
|   envelope,
 | |
|   messageHash,
 | |
|   rawDataMessage,
 | |
|   senderConversationModel,
 | |
|   sentAtTimestamp,
 | |
|   expireUpdate,
 | |
| }: {
 | |
|   envelope: EnvelopePlus;
 | |
|   sentAtTimestamp: number;
 | |
|   rawDataMessage: SignalService.DataMessage;
 | |
|   messageHash: string;
 | |
|   senderConversationModel: ConversationModel;
 | |
|   expireUpdate?: DisappearingMessageUpdate;
 | |
| }): Promise<void> {
 | |
|   window.log.info('handleSwarmDataMessage');
 | |
| 
 | |
|   const cleanDataMessage = cleanIncomingDataMessage(rawDataMessage, envelope);
 | |
|   // we handle group updates from our other devices in handleClosedGroupControlMessage()
 | |
|   if (cleanDataMessage.closedGroupControlMessage) {
 | |
|     await handleClosedGroupControlMessage(
 | |
|       envelope,
 | |
|       cleanDataMessage.closedGroupControlMessage as SignalService.DataMessage.ClosedGroupControlMessage,
 | |
|       expireUpdate || null
 | |
|     );
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * This is a mess, but
 | |
|    *
 | |
|    * 1. if syncTarget is set and this is a synced message, syncTarget holds the conversationId in which this message is addressed.
 | |
|    *    This syncTarget can be a private conversation pubkey or a closed group pubkey
 | |
|    *
 | |
|    * 2. for a closed group message, envelope.senderIdentity is the pubkey of the sender and envelope.source is the pubkey of the closed group.
 | |
|    *
 | |
|    * 3. for a private conversation message, envelope.senderIdentity and envelope.source are probably the pubkey of the sender.
 | |
|    */
 | |
|   const isSyncedMessage = Boolean(cleanDataMessage.syncTarget?.length);
 | |
|   // no need to remove prefix here, as senderIdentity set => envelope.source is not used (and this is the one having the prefix when this is an opengroup)
 | |
|   const convoIdOfSender = envelope.senderIdentity || envelope.source;
 | |
|   const isMe = UserUtils.isUsFromCache(convoIdOfSender);
 | |
| 
 | |
|   if (isSyncedMessage && !isMe) {
 | |
|     window?.log?.warn('Got a sync message from someone else than me. Dropping it.');
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
|   const convoIdToAddTheMessageTo = PubKey.removeTextSecurePrefixIfNeeded(
 | |
|     isSyncedMessage ? cleanDataMessage.syncTarget : envelope.source
 | |
|   );
 | |
| 
 | |
|   const isGroupMessage = !!envelope.senderIdentity;
 | |
|   const isGroupV3Message = isGroupMessage && PubKey.isClosedGroupV3(envelope.source);
 | |
|   let typeOfConvo = ConversationTypeEnum.PRIVATE;
 | |
|   if (isGroupV3Message) {
 | |
|     typeOfConvo = ConversationTypeEnum.GROUPV3;
 | |
|   } else if (isGroupMessage) {
 | |
|     typeOfConvo = ConversationTypeEnum.GROUP;
 | |
|   }
 | |
| 
 | |
|   window?.log?.info(
 | |
|     `Handle dataMessage about convo ${convoIdToAddTheMessageTo} from user: ${convoIdOfSender}`
 | |
|   );
 | |
| 
 | |
|   // remove the prefix from the source object so this is correct for all other
 | |
|   const convoToAddMessageTo = await getConversationController().getOrCreateAndWait(
 | |
|     convoIdToAddTheMessageTo,
 | |
|     typeOfConvo
 | |
|   );
 | |
| 
 | |
|   // Check if we need to update any profile names
 | |
|   if (
 | |
|     !isMe &&
 | |
|     senderConversationModel &&
 | |
|     cleanDataMessage.profile &&
 | |
|     cleanDataMessage.profileKey?.length
 | |
|   ) {
 | |
|     await ProfileManager.updateProfileOfContact(
 | |
|       senderConversationModel.id,
 | |
|       cleanDataMessage.profile.displayName,
 | |
|       cleanDataMessage.profile.profilePicture,
 | |
|       cleanDataMessage.profileKey
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   if (!messageHasVisibleContent(cleanDataMessage)) {
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!convoIdToAddTheMessageTo) {
 | |
|     window?.log?.error('We cannot handle a message without a conversationId');
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   let msgModel =
 | |
|     isSyncedMessage ||
 | |
|     (envelope.senderIdentity && isUsFromCache(envelope.senderIdentity)) ||
 | |
|     (envelope.source && isUsFromCache(envelope.source))
 | |
|       ? createSwarmMessageSentFromUs({
 | |
|           conversationId: convoIdToAddTheMessageTo,
 | |
|           messageHash,
 | |
|           sentAt: sentAtTimestamp,
 | |
|         })
 | |
|       : createSwarmMessageSentFromNotUs({
 | |
|           conversationId: convoIdToAddTheMessageTo,
 | |
|           messageHash,
 | |
|           sender: senderConversationModel.id,
 | |
|           sentAt: sentAtTimestamp,
 | |
|         });
 | |
| 
 | |
|   if (!isEmpty(expireUpdate)) {
 | |
|     msgModel = DisappearingMessages.getMessageReadyToDisappear(
 | |
|       convoToAddMessageTo,
 | |
|       msgModel,
 | |
|       cleanDataMessage.flags,
 | |
|       expireUpdate
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   await handleSwarmMessage(
 | |
|     msgModel,
 | |
|     messageHash,
 | |
|     sentAtTimestamp,
 | |
|     cleanDataMessage,
 | |
|     convoToAddMessageTo,
 | |
|     // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | |
|     () => removeFromCache(envelope)
 | |
|   );
 | |
| }
 | |
| 
 | |
| export async function isSwarmMessageDuplicate({
 | |
|   source,
 | |
|   sentAt,
 | |
| }: {
 | |
|   source: string;
 | |
|   sentAt: number;
 | |
| }) {
 | |
|   try {
 | |
|     const result = (
 | |
|       await Data.getMessagesBySenderAndSentAt([
 | |
|         {
 | |
|           source,
 | |
|           timestamp: sentAt,
 | |
|         },
 | |
|       ])
 | |
|     )?.models?.length;
 | |
| 
 | |
|     return Boolean(result);
 | |
|   } catch (error) {
 | |
|     window?.log?.error('isSwarmMessageDuplicate error:', toLogFormat(error));
 | |
|     return false;
 | |
|   }
 | |
| }
 | |
| 
 | |
| export async function handleOutboxMessageModel(
 | |
|   msgModel: MessageModel,
 | |
|   messageHash: string,
 | |
|   sentAt: number,
 | |
|   rawDataMessage: SignalService.DataMessage,
 | |
|   convoToAddMessageTo: ConversationModel
 | |
| ) {
 | |
|   return handleSwarmMessage(
 | |
|     msgModel,
 | |
|     messageHash,
 | |
|     sentAt,
 | |
|     rawDataMessage,
 | |
|     convoToAddMessageTo,
 | |
|     noop
 | |
|   );
 | |
| }
 | |
| 
 | |
| async function handleSwarmMessage(
 | |
|   msgModel: MessageModel,
 | |
|   messageHash: string,
 | |
|   sentAt: number,
 | |
|   rawDataMessage: SignalService.DataMessage,
 | |
|   convoToAddMessageTo: ConversationModel,
 | |
|   confirm: () => void
 | |
| ): Promise<void> {
 | |
|   if (!rawDataMessage || !msgModel) {
 | |
|     window?.log?.warn('Invalid data passed to handleSwarmMessage.');
 | |
|     confirm();
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   void convoToAddMessageTo.queueJob(async () => {
 | |
|     // this call has to be made inside the queueJob!
 | |
|     // We handle reaction DataMessages separately
 | |
|     if (!msgModel.get('isPublic') && rawDataMessage.reaction) {
 | |
|       await Reactions.handleMessageReaction({
 | |
|         reaction: rawDataMessage.reaction,
 | |
|         sender: msgModel.get('source'),
 | |
|         you: isUsFromCache(msgModel.get('source')),
 | |
|       });
 | |
| 
 | |
|       if (
 | |
|         convoToAddMessageTo.isPrivate() &&
 | |
|         msgModel.get('unread') &&
 | |
|         rawDataMessage.reaction.action === Action.REACT
 | |
|       ) {
 | |
|         msgModel.set('reaction', rawDataMessage.reaction as Reaction);
 | |
|         convoToAddMessageTo.throttledNotify(msgModel);
 | |
|       }
 | |
| 
 | |
|       confirm();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const isDuplicate = await isSwarmMessageDuplicate({
 | |
|       source: msgModel.get('source'),
 | |
|       sentAt,
 | |
|     });
 | |
| 
 | |
|     if (isDuplicate) {
 | |
|       window?.log?.info('Received duplicate message. Dropping it.');
 | |
|       confirm();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     await handleMessageJob(
 | |
|       msgModel,
 | |
|       convoToAddMessageTo,
 | |
|       toRegularMessage(rawDataMessage),
 | |
|       confirm,
 | |
|       msgModel.get('source'),
 | |
|       messageHash
 | |
|     );
 | |
|   });
 | |
| }
 |