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.
		
		
		
		
		
			
		
			
				
	
	
		
			341 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			341 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
| import { Data } from '../../../ts/data/data';
 | |
| import { getMessageQueue } from '..';
 | |
| import { getConversationController } from '../conversations';
 | |
| import { v4 as uuidv4 } from 'uuid';
 | |
| import { UserUtils } from '.';
 | |
| import { ECKeyPair } from '../../receiver/keypairs';
 | |
| import {
 | |
|   ConfigurationMessage,
 | |
|   ConfigurationMessageClosedGroup,
 | |
|   ConfigurationMessageContact,
 | |
| } from '../messages/outgoing/controlMessage/ConfigurationMessage';
 | |
| import { ConversationModel } from '../../models/conversation';
 | |
| import { fromBase64ToArray, fromHexToArray } from './String';
 | |
| import { SignalService } from '../../protobuf';
 | |
| import _ from 'lodash';
 | |
| import {
 | |
|   AttachmentPointerWithUrl,
 | |
|   PreviewWithAttachmentUrl,
 | |
|   Quote,
 | |
|   VisibleMessage,
 | |
| } from '../messages/outgoing/visibleMessage/VisibleMessage';
 | |
| import { ExpirationTimerUpdateMessage } from '../messages/outgoing/controlMessage/ExpirationTimerUpdateMessage';
 | |
| import { OpenGroupData } from '../../data/opengroups';
 | |
| import { getCompleteUrlFromRoom } from '../apis/open_group_api/utils/OpenGroupUtils';
 | |
| import { DURATION } from '../constants';
 | |
| import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
 | |
| import { MessageRequestResponse } from '../messages/outgoing/controlMessage/MessageRequestResponse';
 | |
| import { PubKey } from '../types';
 | |
| 
 | |
| const ITEM_ID_LAST_SYNC_TIMESTAMP = 'lastSyncedTimestamp';
 | |
| 
 | |
| const getLastSyncTimestampFromDb = async (): Promise<number | undefined> =>
 | |
|   (await Data.getItemById(ITEM_ID_LAST_SYNC_TIMESTAMP))?.value;
 | |
| 
 | |
| const writeLastSyncTimestampToDb = async (timestamp: number) =>
 | |
|   Data.createOrUpdateItem({ id: ITEM_ID_LAST_SYNC_TIMESTAMP, value: timestamp });
 | |
| 
 | |
| /**
 | |
|  * Conditionally Syncs user configuration with other devices linked.
 | |
|  */
 | |
| export const syncConfigurationIfNeeded = async () => {
 | |
|   const lastSyncedTimestamp = (await getLastSyncTimestampFromDb()) || 0;
 | |
|   const now = Date.now();
 | |
| 
 | |
|   // if the last sync was less than 2 days before, return early.
 | |
|   if (Math.abs(now - lastSyncedTimestamp) < DURATION.DAYS * 2) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   const allConvos = getConversationController().getConversations();
 | |
| 
 | |
|   const configMessage = await getCurrentConfigurationMessage(allConvos);
 | |
|   try {
 | |
|     // window?.log?.info('syncConfigurationIfNeeded with', configMessage);
 | |
| 
 | |
|     await getMessageQueue().sendSyncMessage(configMessage);
 | |
|   } catch (e) {
 | |
|     window?.log?.warn('Caught an error while sending our ConfigurationMessage:', e);
 | |
|     // we do return early so that next time we use the old timestamp again
 | |
|     // and so try again to trigger a sync
 | |
|     return;
 | |
|   }
 | |
|   await writeLastSyncTimestampToDb(now);
 | |
| };
 | |
| 
 | |
| export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = false) =>
 | |
|   new Promise(resolve => {
 | |
|     const allConvos = getConversationController().getConversations();
 | |
|     // if we hang for more than 10sec, force resolve this promise.
 | |
|     setTimeout(() => {
 | |
|       resolve(false);
 | |
|     }, 10000);
 | |
| 
 | |
|     void getCurrentConfigurationMessage(allConvos)
 | |
|       .then(configMessage => {
 | |
|         // this just adds the message to the sending queue.
 | |
|         // if waitForMessageSent is set, we need to effectively wait until then
 | |
|         // tslint:disable-next-line: no-void-expression
 | |
|         const callback = waitForMessageSent
 | |
|           ? () => {
 | |
|               resolve(true);
 | |
|             }
 | |
|           : undefined;
 | |
|         void getMessageQueue().sendSyncMessage(configMessage, callback as any);
 | |
|         // either we resolve from the callback if we need to wait for it,
 | |
|         // or we don't want to wait, we resolve it here.
 | |
|         if (!waitForMessageSent) {
 | |
|           resolve(true);
 | |
|         }
 | |
|       })
 | |
|       .catch(e => {
 | |
|         window?.log?.warn('Caught an error while building our ConfigurationMessage:', e);
 | |
|         resolve(false);
 | |
|       });
 | |
|   });
 | |
| 
 | |
| const getActiveOpenGroupV2CompleteUrls = async (
 | |
|   convos: Array<ConversationModel>
 | |
| ): Promise<Array<string>> => {
 | |
|   // Filter open groups v2
 | |
|   const openGroupsV2ConvoIds = convos
 | |
|     .filter(c => !!c.get('active_at') && c.isOpenGroupV2() && !c.get('left'))
 | |
|     .map(c => c.id) as Array<string>;
 | |
| 
 | |
|   const urls = await Promise.all(
 | |
|     openGroupsV2ConvoIds.map(async opengroup => {
 | |
|       const roomInfos = OpenGroupData.getV2OpenGroupRoom(opengroup);
 | |
|       if (roomInfos) {
 | |
|         return getCompleteUrlFromRoom(roomInfos);
 | |
|       }
 | |
|       return null;
 | |
|     })
 | |
|   );
 | |
| 
 | |
|   return _.compact(urls) || [];
 | |
| };
 | |
| 
 | |
| const getValidClosedGroups = async (convos: Array<ConversationModel>) => {
 | |
|   const ourPubKey = UserUtils.getOurPubKeyStrFromCache();
 | |
| 
 | |
|   // Filter Closed/Medium groups
 | |
|   const closedGroupModels = convos.filter(
 | |
|     c =>
 | |
|       !!c.get('active_at') &&
 | |
|       c.isMediumGroup() &&
 | |
|       c.get('members')?.includes(ourPubKey) &&
 | |
|       !c.get('left') &&
 | |
|       !c.get('isKickedFromGroup') &&
 | |
|       !c.isBlocked() &&
 | |
|       c.get('displayNameInProfile')
 | |
|   );
 | |
| 
 | |
|   const closedGroups = await Promise.all(
 | |
|     closedGroupModels.map(async c => {
 | |
|       const groupPubKey = c.get('id');
 | |
|       const fetchEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair(groupPubKey);
 | |
|       if (!fetchEncryptionKeyPair) {
 | |
|         return null;
 | |
|       }
 | |
| 
 | |
|       return new ConfigurationMessageClosedGroup({
 | |
|         publicKey: groupPubKey,
 | |
|         name: c.get('displayNameInProfile') || '',
 | |
|         members: c.get('members') || [],
 | |
|         admins: c.get('groupAdmins') || [],
 | |
|         encryptionKeyPair: ECKeyPair.fromHexKeyPair(fetchEncryptionKeyPair),
 | |
|       });
 | |
|     })
 | |
|   );
 | |
| 
 | |
|   const onlyValidClosedGroup = closedGroups.filter(m => m !== null) as Array<
 | |
|     ConfigurationMessageClosedGroup
 | |
|   >;
 | |
|   return onlyValidClosedGroup;
 | |
| };
 | |
| 
 | |
| const getValidContacts = (convos: Array<ConversationModel>) => {
 | |
|   // Filter contacts
 | |
|   // blindedId are synced with the outbox logic.
 | |
|   const contactsModels = convos.filter(
 | |
|     c =>
 | |
|       !!c.get('active_at') &&
 | |
|       c.getRealSessionUsername() &&
 | |
|       c.isPrivate() &&
 | |
|       c.isApproved() &&
 | |
|       !PubKey.hasBlindedPrefix(c.get('id'))
 | |
|   );
 | |
| 
 | |
|   const contacts = contactsModels.map(c => {
 | |
|     try {
 | |
|       const profileKey = c.get('profileKey');
 | |
|       let profileKeyForContact = null;
 | |
|       if (typeof profileKey === 'string') {
 | |
|         // this will throw if the profileKey is not in hex.
 | |
|         try {
 | |
|           // for some reason, at some point, the saved profileKey is a string in base64 format
 | |
|           // this hack is here to update existing conversations with a non-hex profileKey to a hex format and save them
 | |
| 
 | |
|           if (!/^[0-9a-fA-F]+$/.test(profileKey)) {
 | |
|             throw new Error('Not Hex');
 | |
|           }
 | |
|           profileKeyForContact = fromHexToArray(profileKey);
 | |
|         } catch (e) {
 | |
|           // if not hex, try to decode it as base64
 | |
|           profileKeyForContact = fromBase64ToArray(profileKey);
 | |
|           // if the line above does not fail, update the stored profileKey for this convo
 | |
|           void c.setProfileKey(profileKeyForContact);
 | |
|         }
 | |
|       } else if (profileKey) {
 | |
|         window.log.warn(
 | |
|           'Got a profileKey for a contact in another format than string. Contact: ',
 | |
|           c.id
 | |
|         );
 | |
|         return null;
 | |
|       }
 | |
| 
 | |
|       return new ConfigurationMessageContact({
 | |
|         publicKey: c.id as string,
 | |
|         displayName: c.getRealSessionUsername() || 'Anonymous',
 | |
|         profilePictureURL: c.get('avatarPointer'),
 | |
|         profileKey: !profileKeyForContact?.length ? undefined : profileKeyForContact,
 | |
|         isApproved: c.isApproved(),
 | |
|         isBlocked: c.isBlocked(),
 | |
|         didApproveMe: c.didApproveMe(),
 | |
|       });
 | |
|     } catch (e) {
 | |
|       window?.log.warn('getValidContacts', e);
 | |
|       return null;
 | |
|     }
 | |
|   });
 | |
|   return _.compact(contacts);
 | |
| };
 | |
| 
 | |
| export const getCurrentConfigurationMessage = async (
 | |
|   convos: Array<ConversationModel>
 | |
| ): Promise<ConfigurationMessage> => {
 | |
|   const ourPubKey = UserUtils.getOurPubKeyStrFromCache();
 | |
|   const ourConvo = convos.find(convo => convo.id === ourPubKey);
 | |
| 
 | |
|   const opengroupV2CompleteUrls = await getActiveOpenGroupV2CompleteUrls(convos);
 | |
|   const onlyValidClosedGroup = await getValidClosedGroups(convos);
 | |
|   const validContacts = getValidContacts(convos);
 | |
| 
 | |
|   if (!ourConvo) {
 | |
|     window?.log?.error('Could not find our convo while building a configuration message.');
 | |
|   }
 | |
| 
 | |
|   const ourProfileKeyHex =
 | |
|     getConversationController()
 | |
|       .get(UserUtils.getOurPubKeyStrFromCache())
 | |
|       ?.get('profileKey') || null;
 | |
|   const profileKey = ourProfileKeyHex ? fromHexToArray(ourProfileKeyHex) : undefined;
 | |
| 
 | |
|   const profilePicture = ourConvo?.get('avatarPointer') || undefined;
 | |
|   const displayName = ourConvo?.getRealSessionUsername() || 'Anonymous'; // this should never be undefined, but well...
 | |
| 
 | |
|   const activeOpenGroups = [...opengroupV2CompleteUrls];
 | |
| 
 | |
|   return new ConfigurationMessage({
 | |
|     identifier: uuidv4(),
 | |
|     timestamp: Date.now(),
 | |
|     activeOpenGroups,
 | |
|     activeClosedGroups: onlyValidClosedGroup,
 | |
|     displayName,
 | |
|     profilePicture,
 | |
|     profileKey,
 | |
|     contacts: validContacts,
 | |
|   });
 | |
| };
 | |
| 
 | |
| const buildSyncVisibleMessage = (
 | |
|   identifier: string,
 | |
|   dataMessage: SignalService.DataMessage,
 | |
|   timestamp: number,
 | |
|   syncTarget: string
 | |
| ) => {
 | |
|   const body = dataMessage.body || undefined;
 | |
| 
 | |
|   const wrapToUInt8Array = (buffer: any) => {
 | |
|     if (!buffer) {
 | |
|       return undefined;
 | |
|     }
 | |
|     if (buffer instanceof Uint8Array) {
 | |
|       // Audio messages are already uint8Array
 | |
|       return buffer;
 | |
|     }
 | |
|     return new Uint8Array(buffer.toArrayBuffer());
 | |
|   };
 | |
|   const attachments = (dataMessage.attachments || []).map(attachment => {
 | |
|     const key = wrapToUInt8Array(attachment.key);
 | |
|     const digest = wrapToUInt8Array(attachment.digest);
 | |
| 
 | |
|     return {
 | |
|       ...attachment,
 | |
|       key,
 | |
|       digest,
 | |
|     };
 | |
|   }) as Array<AttachmentPointerWithUrl>;
 | |
|   const quote = (dataMessage.quote as Quote) || undefined;
 | |
|   const preview = (dataMessage.preview as Array<PreviewWithAttachmentUrl>) || [];
 | |
|   const expireTimer = dataMessage.expireTimer;
 | |
| 
 | |
|   return new VisibleMessage({
 | |
|     identifier,
 | |
|     timestamp,
 | |
|     attachments,
 | |
|     body,
 | |
|     quote,
 | |
|     preview,
 | |
|     syncTarget,
 | |
|     expireTimer,
 | |
|   });
 | |
| };
 | |
| 
 | |
| const buildSyncExpireTimerMessage = (
 | |
|   identifier: string,
 | |
|   dataMessage: SignalService.DataMessage,
 | |
|   timestamp: number,
 | |
|   syncTarget: string
 | |
| ) => {
 | |
|   const expireTimer = dataMessage.expireTimer;
 | |
| 
 | |
|   return new ExpirationTimerUpdateMessage({
 | |
|     identifier,
 | |
|     timestamp,
 | |
|     expireTimer,
 | |
|     syncTarget,
 | |
|   });
 | |
| };
 | |
| 
 | |
| export type SyncMessageType =
 | |
|   | VisibleMessage
 | |
|   | ExpirationTimerUpdateMessage
 | |
|   | ConfigurationMessage
 | |
|   | MessageRequestResponse
 | |
|   | UnsendMessage;
 | |
| 
 | |
| export const buildSyncMessage = (
 | |
|   identifier: string,
 | |
|   dataMessage: SignalService.DataMessage,
 | |
|   syncTarget: string,
 | |
|   sentTimestamp: number
 | |
| ): VisibleMessage | ExpirationTimerUpdateMessage => {
 | |
|   if (
 | |
|     (dataMessage as any).constructor.name !== 'DataMessage' &&
 | |
|     !(dataMessage instanceof SignalService.DataMessage)
 | |
|   ) {
 | |
|     window?.log?.warn('buildSyncMessage with something else than a DataMessage');
 | |
|   }
 | |
| 
 | |
|   if (!sentTimestamp || !_.isNumber(sentTimestamp)) {
 | |
|     throw new Error('Tried to build a sync message without a sentTimestamp');
 | |
|   }
 | |
|   // don't include our profileKey on syncing message. This is to be done by a ConfigurationMessage now
 | |
|   const timestamp = _.toNumber(sentTimestamp);
 | |
|   if (dataMessage.flags === SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE) {
 | |
|     return buildSyncExpireTimerMessage(identifier, dataMessage, timestamp, syncTarget);
 | |
|   }
 | |
|   return buildSyncVisibleMessage(identifier, dataMessage, timestamp, syncTarget);
 | |
| };
 |