signal-desktop/ts/util/deleteForMe.ts

434 lines
11 KiB
TypeScript

// 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 dataInterface, { 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,
removeMessagesInConversation,
saveMessage,
} = dataInterface;
export function doesMessageMatch({
conversationId,
message,
query,
sentTimestamps,
}: {
message: MessageAttributesType;
conversationId: string;
query: MessageQuery;
sentTimestamps: ReadonlySet<number>;
}): 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<MessageAttributesType | undefined> {
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<boolean> {
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<void> {
await deleteAndCleanup([message], logId, {
fromSync: true,
singleProtoJobQueue,
});
}
export async function deleteAttachmentFromMessage(
conversationId: string,
targetMessage: MessageToDelete,
deleteAttachmentData: {
clientUuid?: string;
fallbackDigest?: string;
fallbackPlaintextHash?: string;
},
{
deleteOnDisk,
logId,
}: {
deleteOnDisk: (path: string) => Promise<void>;
logId: string;
}
): Promise<boolean> {
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,
logId,
shouldSave: true,
});
}
export async function applyDeleteAttachmentFromMessage(
message: MessageModel,
{
clientUuid,
fallbackDigest,
fallbackPlaintextHash,
}: {
clientUuid?: string;
fallbackDigest?: string;
fallbackPlaintextHash?: string;
},
{
deleteOnDisk,
shouldSave,
logId,
}: {
deleteOnDisk: (path: string) => Promise<void>;
shouldSave: boolean;
logId: string;
}
): Promise<boolean> {
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<boolean> {
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)(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;
}
export async function deleteConversation(
conversation: ConversationModel,
mostRecentMessages: Array<MessageToDelete>,
isFullDelete: boolean,
logId: string
): Promise<boolean> {
const queries = mostRecentMessages.map(getMessageQueryFromTarget);
const found = await Promise.all(
queries.map(query => findMatchingMessage(conversation.id, query))
);
const sorted = sortBy(found, 'received_at');
const newestMessage = last(sorted);
if (newestMessage) {
const { received_at: receivedAt } = newestMessage;
await removeMessagesInConversation(conversation.id, {
fromSync: true,
receivedAt,
logId: `${logId}(receivedAt=${receivedAt})`,
singleProtoJobQueue,
});
}
if (!newestMessage) {
log.warn(`${logId}: Found no target messages for delete`);
}
if (isFullDelete) {
log.info(`${logId}: isFullDelete=true, proceeding to local-only delete`);
return deleteLocalOnlyConversation(conversation, logId);
}
return true;
}
export async function deleteLocalOnlyConversation(
conversation: ConversationModel,
logId: string
): Promise<boolean> {
const limit = 1;
const messages = await getMostRecentAddressableMessages(
conversation.id,
limit
);
if (messages.length > 0) {
log.warn(
`${logId}: Attempted local-only delete but 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;
}