// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { last, sortBy } from 'lodash'; import * as log from '../logging/log'; import { isAciString } from './isAciString'; import { isGroup, isGroupV2 } from './whatTypeOfConversation'; import { getConversationIdForLogging, getMessageIdForLogging, } from './idForLogging'; import { missingCaseError } from './missingCaseError'; import { getMessageSentTimestampSet } from './getMessageSentTimestampSet'; import { getAuthor } from '../messages/helpers'; import { isPniString } from '../types/ServiceId'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client'; import { deleteData } from '../types/Attachment'; import type { ConversationAttributesType, MessageAttributesType, } from '../model-types'; import type { ConversationModel } from '../models/conversations'; import type { ConversationToDelete, MessageToDelete, } from '../textsecure/messageReceiverEvents'; import type { AciString, PniString } from '../types/ServiceId'; import type { AttachmentType } from '../types/Attachment'; import type { MessageModel } from '../models/messages'; const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader; const { removeMessagesInConversation, saveMessage } = DataWriter; export function doesMessageMatch({ conversationId, message, query, sentTimestamps, }: { message: MessageAttributesType; conversationId: string; query: MessageQuery; sentTimestamps: ReadonlySet; }): boolean { const author = getAuthor(message); const conversationMatches = message.conversationId === conversationId; const aciMatches = query.authorAci && author?.attributes.serviceId === query.authorAci; const pniMatches = query.authorPni && author?.attributes.serviceId === query.authorPni; const e164Matches = query.authorE164 && author?.attributes.e164 === query.authorE164; const timestampMatches = sentTimestamps.has(query.sentAt); return Boolean( conversationMatches && timestampMatches && (aciMatches || e164Matches || pniMatches) ); } export async function findMatchingMessage( conversationId: string, query: MessageQuery ): Promise { const sentAtMatches = await getMessagesBySentAt(query.sentAt); if (!sentAtMatches.length) { return undefined; } return sentAtMatches.find(message => { const sentTimestamps = getMessageSentTimestampSet(message); return doesMessageMatch({ conversationId, message, query, sentTimestamps, }); }); } export async function deleteMessage( conversationId: string, targetMessage: MessageToDelete, logId: string ): Promise { const query = getMessageQueryFromTarget(targetMessage); const found = await findMatchingMessage(conversationId, query); if (!found) { log.warn(`${logId}/deleteMessage: Couldn't find matching message`); return false; } const message = window.MessageCache.toMessageAttributes(found); await applyDeleteMessage(message, logId); return true; } export async function applyDeleteMessage( message: MessageAttributesType, logId: string ): Promise { await deleteAndCleanup([message], logId, { fromSync: true, singleProtoJobQueue, }); } export async function deleteAttachmentFromMessage( conversationId: string, targetMessage: MessageToDelete, deleteAttachmentData: { clientUuid?: string; fallbackDigest?: string; fallbackPlaintextHash?: string; }, { deleteOnDisk, deleteDownloadOnDisk, logId, }: { deleteOnDisk: (path: string) => Promise; deleteDownloadOnDisk: (path: string) => Promise; logId: string; } ): Promise { const query = getMessageQueryFromTarget(targetMessage); const found = await findMatchingMessage(conversationId, query); if (!found) { log.warn( `${logId}/deleteAttachmentFromMessage: Couldn't find matching message` ); return false; } const message = window.MessageCache.__DEPRECATED$register( found.id, found, 'ReadSyncs.onSync' ); return applyDeleteAttachmentFromMessage(message, deleteAttachmentData, { deleteOnDisk, deleteDownloadOnDisk, logId, shouldSave: true, }); } export async function applyDeleteAttachmentFromMessage( message: MessageModel, { clientUuid, fallbackDigest, fallbackPlaintextHash, }: { clientUuid?: string; fallbackDigest?: string; fallbackPlaintextHash?: string; }, { deleteOnDisk, deleteDownloadOnDisk, shouldSave, logId, }: { deleteOnDisk: (path: string) => Promise; deleteDownloadOnDisk: (path: string) => Promise; shouldSave: boolean; logId: string; } ): Promise { if (!clientUuid && !fallbackDigest && !fallbackPlaintextHash) { log.warn( `${logId}/deleteAttachmentFromMessage: No clientUuid, fallbackDigest or fallbackPlaintextHash` ); return true; } const ourAci = window.textsecure.storage.user.getCheckedAci(); const attachments = message.get('attachments'); if (!attachments || attachments.length === 0) { log.warn( `${logId}/deleteAttachmentFromMessage: No attachments on target message` ); return true; } async function checkFieldAndDelete( value: string | undefined, valueName: string, fieldName: keyof AttachmentType ): Promise { if (value) { const attachment = attachments?.find( item => item.digest && item[fieldName] === value ); if (attachment) { message.set({ attachments: attachments?.filter(item => item !== attachment), }); if (shouldSave) { await saveMessage(message.attributes, { ourAci }); } await deleteData({ deleteOnDisk, deleteDownloadOnDisk })(attachment); return true; } log.warn( `${logId}/deleteAttachmentFromMessage: No attachment found with provided ${valueName}` ); } else { log.warn( `${logId}/deleteAttachmentFromMessage: No ${valueName} provided` ); } return false; } let result: boolean; result = await checkFieldAndDelete(clientUuid, 'clientUuid', 'clientUuid'); if (result) { return true; } result = await checkFieldAndDelete( fallbackDigest, 'fallbackDigest', 'digest' ); if (result) { return true; } result = await checkFieldAndDelete( fallbackPlaintextHash, 'fallbackPlaintextHash', 'plaintextHash' ); if (result) { return true; } log.warn( `${logId}/deleteAttachmentFromMessage: Couldn't find target attachment` ); return true; } async function getMostRecentMatchingMessage( conversationId: string, targetMessages: Array ): Promise { const queries = targetMessages.map(getMessageQueryFromTarget); const found = await Promise.all( queries.map(query => findMatchingMessage(conversationId, query)) ); const sorted = sortBy(found, 'received_at'); return last(sorted); } export async function deleteConversation( conversation: ConversationModel, mostRecentMessages: Array, mostRecentNonExpiringMessages: Array | undefined, isFullDelete: boolean, providedLogId: string ): Promise { const logId = `${providedLogId}/deleteConversation`; const newestMessage = await getMostRecentMatchingMessage( conversation.id, mostRecentMessages ); if (!newestMessage) { log.warn(`${logId}: Found no messages from mostRecentMessages set`); } else { log.info(`${logId}: Found most recent message from mostRecentMessages set`); const { received_at: receivedAt } = newestMessage; await removeMessagesInConversation(conversation.id, { fromSync: true, receivedAt, logId: `${logId}(receivedAt=${receivedAt})`, singleProtoJobQueue, }); } if (!newestMessage && mostRecentNonExpiringMessages?.length) { const newestNondisappearingMessage = await getMostRecentMatchingMessage( conversation.id, mostRecentNonExpiringMessages ); if (!newestNondisappearingMessage) { log.warn( `${logId}: Found no messages from mostRecentNonExpiringMessages set` ); } else { log.info( `${logId}: Found most recent message from mostRecentNonExpiringMessages set` ); const { received_at: receivedAt } = newestNondisappearingMessage; await removeMessagesInConversation(conversation.id, { fromSync: true, receivedAt, logId: `${logId}(receivedAt=${receivedAt})`, singleProtoJobQueue, }); } } if (isFullDelete) { log.info(`${logId}: isFullDelete=true, proceeding to local-only delete`); return deleteLocalOnlyConversation(conversation, providedLogId); } return true; } export async function deleteLocalOnlyConversation( conversation: ConversationModel, providedLogId: string ): Promise { const logId = `${providedLogId}/deleteLocalOnlyConversation`; const limit = 1; const messages = await getMostRecentAddressableMessages( conversation.id, limit ); if (messages.length > 0) { log.warn(`${logId}: Cannot delete; found an addressable message`); return false; } // This will delete all messages and remove the conversation from the left pane. // We need to call destroyMessagesInner, since we're already in conversation.queueJob() await conversation.destroyMessagesInner({ logId, source: 'local-delete-sync', }); return true; } export function getConversationFromTarget( targetConversation: ConversationToDelete ): ConversationModel | undefined { const { type } = targetConversation; if (type === 'aci') { return window.ConversationController.get(targetConversation.aci); } if (type === 'group') { return window.ConversationController.get(targetConversation.groupId); } if (type === 'e164') { return window.ConversationController.get(targetConversation.e164); } if (type === 'pni') { return window.ConversationController.get(targetConversation.pni); } throw missingCaseError(type); } type MessageQuery = { sentAt: number; authorAci?: AciString; authorE164?: string; authorPni?: PniString; }; export function getMessageQueryFromTarget( targetMessage: MessageToDelete ): MessageQuery { const { type, sentAt } = targetMessage; if (type === 'aci') { if (!isAciString(targetMessage.authorAci)) { throw new Error('Provided authorAci was not an ACI!'); } return { sentAt, authorAci: targetMessage.authorAci }; } if (type === 'pni') { if (!isPniString(targetMessage.authorPni)) { throw new Error('Provided authorPni was not a PNI!'); } return { sentAt, authorPni: targetMessage.authorPni }; } if (type === 'e164') { return { sentAt, authorE164: targetMessage.authorE164 }; } throw missingCaseError(type); } export function getConversationToDelete( attributes: ConversationAttributesType ): ConversationToDelete { const { groupId, serviceId: aci, e164 } = attributes; const idForLogging = getConversationIdForLogging(attributes); const logId = `getConversationToDelete(${idForLogging})`; if (isGroupV2(attributes) && groupId) { return { type: 'group', groupId, }; } if (isGroup(attributes)) { throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`); } if (aci && isAciString(aci)) { return { type: 'aci', aci, }; } if (e164) { return { type: 'e164', e164, }; } throw new Error(`${logId}: No valid identifier found!`); } export function getMessageToDelete( attributes: MessageAttributesType ): MessageToDelete | undefined { const logId = `getMessageToDelete(${getMessageIdForLogging(attributes)})`; const { sent_at: sentAt } = attributes; const author = getAuthor(attributes); const authorAci = author?.get('serviceId'); const authorE164 = author?.get('e164'); if (authorAci && isAciString(authorAci)) { return { type: 'aci' as const, authorAci, sentAt, }; } if (authorE164) { return { type: 'e164' as const, authorE164, sentAt, }; } log.warn(`${logId}: Message was missing source ACI/e164`); return undefined; }