From ac04d02d4f371dc4b0fe6fcde9f33f221b054686 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 21 Jun 2024 15:35:18 -0700 Subject: [PATCH] Support for single-attachment delete synced across devices --- protos/Backups.proto | 3 + protos/SignalService.proto | 57 ++++--- ts/jobs/helpers/sendNormalMessage.ts | 2 + ts/messageModifiers/DeletesForMe.ts | 28 +++- ts/services/backups/export.ts | 2 + ts/services/backups/util/filePointers.ts | 9 +- ts/state/ducks/audioRecorder.ts | 2 + ts/state/ducks/composer.ts | 8 +- ts/test-both/helpers/fakeAttachment.ts | 3 + ts/test-both/processDataMessage_test.ts | 5 + ts/test-both/state/ducks/composer_test.ts | 2 + ts/test-electron/backup/attachments_test.ts | 17 ++- ts/textsecure/MessageReceiver.ts | 62 ++++++++ ts/textsecure/SendMessage.ts | 4 + ts/textsecure/Types.d.ts | 3 +- ts/textsecure/messageReceiverEvents.ts | 10 ++ ts/textsecure/processDataMessage.ts | 4 +- ts/types/Attachment.ts | 5 + ts/util/deleteForMe.ts | 159 +++++++++++++++++++- ts/util/handleImageAttachment.ts | 1 + ts/util/handleVideoAttachment.ts | 2 + ts/util/modifyTargetMessage.ts | 49 +++++- ts/util/processAttachment.ts | 4 + ts/util/resolveDraftAttachmentOnDisk.ts | 1 + ts/util/syncTasks.ts | 18 +++ ts/util/uploadAttachment.ts | 17 ++- 26 files changed, 422 insertions(+), 55 deletions(-) diff --git a/protos/Backups.proto b/protos/Backups.proto index c08a3a0b920..a1988c59b62 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -567,6 +567,9 @@ message MessageAttachment { FilePointer pointer = 1; Flag flag = 2; bool wasDownloaded = 3; + // Cross-client identifier for this attachment among all attachments on the + // owning message. See: SignalService.AttachmentPointer.clientUuid. + optional bytes clientUuid = 4; } message FilePointer { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 392449671c5..66d10a29951 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -655,6 +655,17 @@ message SyncMessage { repeated AddressableMessage messages = 2; } + message AttachmentDelete { + optional ConversationIdentifier conversation = 1; + optional AddressableMessage targetMessage = 2; + // The `clientUuid` from `AttachmentPointer`. + optional bytes clientUuid = 3; + // SHA256 hash of the (encrypted, padded, etc.) attachment blob on the CDN. + optional bytes fallbackDigest = 4; + // SHA256 hash of the plaintext content of the attachment. + optional bytes fallbackPlaintextHash = 5; + } + message ConversationDelete { optional ConversationIdentifier conversation = 1; repeated AddressableMessage mostRecentMessages = 2; @@ -668,6 +679,7 @@ message SyncMessage { repeated MessageDeletes messageDeletes = 1; repeated ConversationDelete conversationDeletes = 2; repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; + repeated AttachmentDelete attachmentDeletes = 4; } optional Sent sent = 1; @@ -697,30 +709,37 @@ message SyncMessage { message AttachmentPointer { enum Flags { VOICE_MESSAGE = 1; - BORDERLESS = 2; + BORDERLESS = 2; // Our parser does not handle reserved in enums: DESKTOP-1569 - // reserved 4; - GIF = 8; + // reserved 4; + GIF = 8; } oneof attachment_identifier { - fixed64 cdnId = 1; - string cdnKey = 15; + fixed64 cdnId = 1; + string cdnKey = 15; } - optional string contentType = 2; - optional bytes key = 3; - optional uint32 size = 4; - optional bytes thumbnail = 5; - optional bytes digest = 6; - optional string fileName = 7; - optional uint32 flags = 8; - optional uint32 width = 9; - optional uint32 height = 10; - optional string caption = 11; - optional string blurHash = 12; - optional uint64 uploadTimestamp = 13; - optional uint32 cdnNumber = 14; - // Next ID: 16 + // Cross-client identifier for this attachment among all attachments on the + // owning message. + optional bytes clientUuid = 20; + optional string contentType = 2; + optional bytes key = 3; + optional uint32 size = 4; + optional bytes thumbnail = 5; + optional bytes digest = 6; + reserved /* incrementalMac with implicit chunk sizing */ 16; + reserved /* incrementalMac for all attachment types */ 18; + optional bytes incrementalMac = 19; + optional uint32 chunkSize = 17; + optional string fileName = 7; + optional uint32 flags = 8; + optional uint32 width = 9; + optional uint32 height = 10; + optional string caption = 11; + optional string blurHash = 12; + optional uint64 uploadTimestamp = 13; + optional uint32 cdnNumber = 14; + // Next ID: 21 } message GroupContextV2 { diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index c63165a3de9..e733f7d75a4 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -3,6 +3,7 @@ import { isNumber } from 'lodash'; import PQueue from 'p-queue'; +import { v4 as generateUuid } from 'uuid'; import * as Errors from '../../types/errors'; import { strictAssert } from '../../util/assert'; @@ -589,6 +590,7 @@ async function getMessageSendData({ maybeLongAttachment = { contentType: LONG_MESSAGE, + clientUuid: generateUuid(), fileName: `long-message-${targetTimestamp}.txt`, data, size: data.byteLength, diff --git a/ts/messageModifiers/DeletesForMe.ts b/ts/messageModifiers/DeletesForMe.ts index 3a1b01b566d..9177153afed 100644 --- a/ts/messageModifiers/DeletesForMe.ts +++ b/ts/messageModifiers/DeletesForMe.ts @@ -12,6 +12,7 @@ import type { MessageToDelete, } from '../textsecure/messageReceiverEvents'; import { + deleteAttachmentFromMessage, deleteMessage, doesMessageMatch, getConversationFromTarget, @@ -23,6 +24,11 @@ const { removeSyncTaskById } = dataInterface; export type DeleteForMeAttributesType = { conversation: ConversationToDelete; + deleteAttachmentData?: { + clientUuid?: string; + fallbackDigest?: string; + fallbackPlaintextHash?: string; + }; envelopeId: string; message: MessageToDelete; syncTaskId: string; @@ -91,11 +97,23 @@ export async function onDelete(item: DeleteForMeAttributesType): Promise { conversation.queueJob('DeletesForMe.onDelete', async () => { log.info(`${logId}: Starting...`); - const result = await deleteMessage( - conversation.id, - item.message, - logId - ); + let result: boolean; + if (item.deleteAttachmentData) { + // This will find the message, then work with a backbone model to mirror what + // modifyTargetMessage does. + result = await deleteAttachmentFromMessage( + conversation.id, + item.message, + item.deleteAttachmentData, + { + deleteOnDisk: window.Signal.Migrations.deleteAttachmentData, + logId, + } + ); + } else { + // This automatically notifies redux + result = await deleteMessage(conversation.id, item.message, logId); + } if (result) { await remove(item); } diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 2a0ab6c6385..1cc8f32d5c6 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -1833,6 +1833,7 @@ export class BackupExportStream extends Readable { backupLevel: BackupLevel; messageReceivedAt: number; }): Promise { + const { clientUuid } = attachment; const filePointer = await this.processAttachment({ attachment, backupLevel, @@ -1843,6 +1844,7 @@ export class BackupExportStream extends Readable { pointer: filePointer, flag: this.getMessageAttachmentFlag(attachment), wasDownloaded: isDownloaded(attachment), // should always be true + clientUuid: clientUuid ? uuidToBytes(clientUuid) : undefined, }); } diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts index 52ea0788374..e5f7a22122c 100644 --- a/ts/services/backups/util/filePointers.ts +++ b/ts/services/backups/util/filePointers.ts @@ -38,6 +38,7 @@ import { import { redactGenericText } from '../../../util/privacy'; import { missingCaseError } from '../../../util/missingCaseError'; import { toLogFormat } from '../../../types/errors'; +import { bytesToUuid } from '../../../util/uuidToBytes'; export function convertFilePointerToAttachment( filePointer: Backups.FilePointer @@ -128,10 +129,16 @@ export function convertFilePointerToAttachment( export function convertBackupMessageAttachmentToAttachment( messageAttachment: Backups.IMessageAttachment ): AttachmentType | null { + const { clientUuid } = messageAttachment; + if (!messageAttachment.pointer) { return null; } - const result = convertFilePointerToAttachment(messageAttachment.pointer); + const result = { + ...convertFilePointerToAttachment(messageAttachment.pointer), + clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined, + }; + switch (messageAttachment.flag) { case Backups.MessageAttachment.Flag.VOICE_MESSAGE: result.flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE; diff --git a/ts/state/ducks/audioRecorder.ts b/ts/state/ducks/audioRecorder.ts index ec5fb709b10..4ba7e2fc42b 100644 --- a/ts/state/ducks/audioRecorder.ts +++ b/ts/state/ducks/audioRecorder.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction } from 'redux-thunk'; +import { v4 as generateUuid } from 'uuid'; import type { ReadonlyDeep } from 'type-fest'; import * as log from '../../logging/log'; @@ -172,6 +173,7 @@ export function completeRecording( const voiceNoteAttachment: InMemoryAttachmentDraftType = { pending: false, + clientUuid: generateUuid(), contentType: stringToMIMEType(blob.type), data, size: data.byteLength, diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index bbe766ccf12..19d1242b21c 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -799,6 +799,7 @@ function addAttachment( // We do async operations first so multiple in-process addAttachments don't stomp on // each other. const onDisk = await writeDraftAttachment(attachment); + const toAdd = { ...onDisk, clientUuid: generateUuid() }; const state = getState(); @@ -822,7 +823,7 @@ function addAttachment( // User has canceled the draft so we don't need to continue processing if (!hasDraftAttachmentPending) { - await deleteDraftAttachment(onDisk); + await deleteDraftAttachment(toAdd); return; } @@ -835,9 +836,9 @@ function addAttachment( log.warn( `addAttachment: Failed to find pending attachment with path ${attachment.path}` ); - nextAttachments = [...draftAttachments, onDisk]; + nextAttachments = [...draftAttachments, toAdd]; } else { - nextAttachments = replaceIndex(draftAttachments, index, onDisk); + nextAttachments = replaceIndex(draftAttachments, index, toAdd); } replaceAttachments(conversationId, nextAttachments)( @@ -1165,6 +1166,7 @@ function getPendingAttachment(file: File): AttachmentDraftType | undefined { return { contentType: fileType, + clientUuid: generateUuid(), fileName, size: file.size, path: file.name, diff --git a/ts/test-both/helpers/fakeAttachment.ts b/ts/test-both/helpers/fakeAttachment.ts index 5b2aa8a8924..c9b734d1bca 100644 --- a/ts/test-both/helpers/fakeAttachment.ts +++ b/ts/test-both/helpers/fakeAttachment.ts @@ -1,6 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { v4 as generateUuid } from 'uuid'; + import type { AttachmentType, AttachmentDraftType, @@ -33,6 +35,7 @@ export const fakeDraftAttachment = ( overrides: Partial = {} ): AttachmentDraftType => ({ pending: false, + clientUuid: generateUuid(), contentType: IMAGE_JPEG, path: 'file.jpg', size: 10304, diff --git a/ts/test-both/processDataMessage_test.ts b/ts/test-both/processDataMessage_test.ts index c06a237de93..b644b27b0d4 100644 --- a/ts/test-both/processDataMessage_test.ts +++ b/ts/test-both/processDataMessage_test.ts @@ -3,6 +3,7 @@ import { assert } from 'chai'; import Long from 'long'; +import { v4 as generateUuid } from 'uuid'; import { processDataMessage, @@ -12,14 +13,17 @@ import type { ProcessedAttachment } from '../textsecure/Types.d'; import { SignalService as Proto } from '../protobuf'; import { IMAGE_GIF, IMAGE_JPEG } from '../types/MIME'; import { generateAci } from '../types/ServiceId'; +import { uuidToBytes } from '../util/uuidToBytes'; const ACI_1 = generateAci(); const FLAGS = Proto.DataMessage.Flags; const TIMESTAMP = Date.now(); +const CLIENT_UUID = generateUuid(); const UNPROCESSED_ATTACHMENT: Proto.IAttachmentPointer = { cdnId: Long.fromNumber(123), + clientUuid: uuidToBytes(CLIENT_UUID), key: new Uint8Array([1, 2, 3]), digest: new Uint8Array([4, 5, 6]), contentType: IMAGE_GIF, @@ -28,6 +32,7 @@ const UNPROCESSED_ATTACHMENT: Proto.IAttachmentPointer = { const PROCESSED_ATTACHMENT: ProcessedAttachment = { cdnId: '123', + clientUuid: CLIENT_UUID, key: 'AQID', digest: 'BAUG', contentType: IMAGE_GIF, diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts index ed28f464008..1712f9778b2 100644 --- a/ts/test-both/state/ducks/composer_test.ts +++ b/ts/test-both/state/ducks/composer_test.ts @@ -4,6 +4,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { noop } from 'lodash'; +import { v4 as generateUuid } from 'uuid'; import type { ReduxActions } from '../../../state/types'; import { @@ -68,6 +69,7 @@ describe('both/state/ducks/composer', () => { const attachments: Array = [ { contentType: IMAGE_JPEG, + clientUuid: generateUuid(), pending: true, size: 2433, path: 'image.jpg', diff --git a/ts/test-electron/backup/attachments_test.ts b/ts/test-electron/backup/attachments_test.ts index a59bdd597e0..15c5ae8b129 100644 --- a/ts/test-electron/backup/attachments_test.ts +++ b/ts/test-electron/backup/attachments_test.ts @@ -84,6 +84,7 @@ describe('backup/attachments', () => { return { cdnKey: `cdnKey${index}`, cdnNumber: 3, + clientUuid: generateGuid(), key: getBase64(`key${index}`), digest: getBase64(`digest${index}`), iv: getBase64(`iv${index}`), @@ -212,7 +213,7 @@ describe('backup/attachments', () => { describe('Preview attachments', () => { it('BackupLevel.Messages, roundtrips preview attachments', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); await asymmetricRoundtripHarness( [ @@ -236,7 +237,7 @@ describe('backup/attachments', () => { ); }); it('BackupLevel.Media, roundtrips preview attachments', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); await asymmetricRoundtripHarness( @@ -283,7 +284,7 @@ describe('backup/attachments', () => { describe('contact attachments', () => { it('BackupLevel.Messages, roundtrips contact attachments', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); await asymmetricRoundtripHarness( [ @@ -308,7 +309,7 @@ describe('backup/attachments', () => { ); }); it('BackupLevel.Media, roundtrips contact attachments', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); await asymmetricRoundtripHarness( @@ -346,7 +347,7 @@ describe('backup/attachments', () => { describe('quotes', () => { it('BackupLevel.Messages, roundtrips quote attachments', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); const authorAci = generateAci(); const quotedMessage: QuotedMessageType = { authorAci, @@ -383,7 +384,7 @@ describe('backup/attachments', () => { ); }); it('BackupLevel.Media, roundtrips quote attachments', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); const authorAci = generateAci(); const quotedMessage: QuotedMessageType = { @@ -435,7 +436,7 @@ describe('backup/attachments', () => { attachments: [existingAttachment], }); - const quoteAttachment = composeAttachment(2); + const quoteAttachment = composeAttachment(2, { clientUuid: undefined }); delete quoteAttachment.thumbnail; strictAssert(quoteAttachment.digest, 'digest exists'); @@ -687,7 +688,7 @@ describe('backup/attachments', () => { }); describe('when this device sent sticker (i.e. encryption info exists on message)', () => { it('roundtrips sticker', async () => { - const attachment = composeAttachment(1); + const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); await asymmetricRoundtripHarness( [ diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index b0f09386287..07d1afb7c2c 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -156,6 +156,7 @@ import { getCallEventForProto } from '../util/callDisposition'; import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey'; import { CallLogEvent } from '../types/CallDisposition'; import { CallLinkUpdateSyncType } from '../types/CallLink'; +import { bytesToUuid } from '../util/uuidToBytes'; const GROUPV2_ID_LENGTH = 32; const RETRY_TIMEOUT = 2 * 60 * 1000; @@ -3761,6 +3762,67 @@ export default class MessageReceiver eventData = eventData.concat(localOnlyConversationDeletes); } + if (deleteSync.attachmentDeletes?.length) { + const attachmentDeletes: Array = + deleteSync.attachmentDeletes + .map(item => { + const { + clientUuid: targetClientUuid, + conversation: targetConversation, + fallbackDigest: targetFallbackDigest, + fallbackPlaintextHash: targetFallbackPlaintextHash, + targetMessage, + } = item; + const conversation = targetConversation + ? processConversationToDelete(targetConversation, logId) + : undefined; + const message = targetMessage + ? processMessageToDelete(targetMessage, logId) + : undefined; + + if (!conversation) { + log.warn( + `${logId}/handleDeleteForMeSync/attachmentDeletes: No target conversation` + ); + return undefined; + } + if (!message) { + log.warn( + `${logId}/handleDeleteForMeSync/attachmentDeletes: No target message` + ); + return undefined; + } + const clientUuid = targetClientUuid?.length + ? bytesToUuid(targetClientUuid) + : undefined; + const fallbackDigest = targetFallbackDigest?.length + ? Bytes.toBase64(targetFallbackDigest) + : undefined; + // TODO: DESKTOP-7204 + const fallbackPlaintextHash = targetFallbackPlaintextHash?.length + ? Bytes.toHex(targetFallbackPlaintextHash) + : undefined; + if (!clientUuid && !fallbackDigest && !fallbackPlaintextHash) { + log.warn( + `${logId}/handleDeleteForMeSync/attachmentDeletes: Missing clientUuid, fallbackDigest and fallbackPlaintextHash` + ); + return undefined; + } + + return { + type: 'delete-single-attachment' as const, + conversation, + message, + clientUuid, + fallbackDigest, + fallbackPlaintextHash, + timestamp, + }; + }) + .filter(isNotNil); + + eventData = eventData.concat(attachmentDeletes); + } if (!eventData.length) { throw new Error(`${logId}: Nothing found in sync message!`); } diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 9e952f60509..c208d4cd1d6 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1534,6 +1534,10 @@ export default class MessageSender { deleteForMe.localOnlyConversationDeletes.push({ conversation, }); + } else if (item.type === 'delete-single-attachment') { + throw new Error( + "getDeleteForMeSyncMessage: Desktop currently does not support sending 'delete-single-attachment' messages" + ); } else { throw missingCaseError(item); } diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 7c0b76dfda2..6fee83541b2 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -105,8 +105,9 @@ export type ProcessedEnvelope = Readonly<{ export type ProcessedAttachment = { cdnId?: string; cdnKey?: string; - digest?: string; contentType: MIMEType; + clientUuid?: string; + digest?: string; key?: string; size: number; fileName?: string; diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 33e5976b354..b3e6cdc7f0c 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -526,10 +526,20 @@ export const deleteLocalConversationSchema = z.object({ conversation: conversationToDeleteSchema, timestamp: z.number(), }); +export const deleteAttachmentSchema = z.object({ + type: z.literal('delete-single-attachment').readonly(), + conversation: conversationToDeleteSchema, + message: messageToDeleteSchema, + clientUuid: z.string().optional(), + fallbackDigest: z.string().optional(), + fallbackPlaintextHash: z.string().optional(), + timestamp: z.number(), +}); export const deleteForMeSyncTargetSchema = z.union([ deleteMessageSchema, deleteConversationSchema, deleteLocalConversationSchema, + deleteAttachmentSchema, ]); export type DeleteForMeSyncTarget = z.infer; diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index 4c8b540d02d..ab63f59a7c1 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -31,6 +31,7 @@ import { PaymentEventKind } from '../types/Payment'; import { filterAndClean } from '../types/BodyRange'; import { isAciString } from '../util/isAciString'; import { normalizeAci } from '../util/normalizeAci'; +import { bytesToUuid } from '../util/uuidToBytes'; const FLAGS = Proto.DataMessage.Flags; export const ATTACHMENT_MAX = 32; @@ -52,7 +53,7 @@ export function processAttachment( const { cdnId } = attachment; const hasCdnId = Long.isLong(cdnId) ? !cdnId.isZero() : Boolean(cdnId); - const { contentType, digest, key, size } = attachment; + const { clientUuid, contentType, digest, key, size } = attachment; if (!isNumber(size)) { throw new Error('Missing size on incoming attachment!'); } @@ -61,6 +62,7 @@ export function processAttachment( ...shallowDropNull(attachment), cdnId: hasCdnId ? String(cdnId) : undefined, + clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined, contentType: contentType ? stringToMIMEType(contentType) : APPLICATION_OCTET_STREAM, diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index d6fa681b7ee..b56c63b39e9 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -44,6 +44,7 @@ export type AttachmentType = { error?: boolean; blurHash?: string; caption?: string; + clientUuid?: string; contentType: MIME.MIMEType; digest?: string; fileName?: string; @@ -154,6 +155,7 @@ export type BaseAttachmentDraftType = { export type InMemoryAttachmentDraftType = | ({ data: Uint8Array; + clientUuid: string; pending: false; screenshotData?: Uint8Array; fileName?: string; @@ -161,6 +163,7 @@ export type InMemoryAttachmentDraftType = } & BaseAttachmentDraftType) | { contentType: MIME.MIMEType; + clientUuid: string; fileName?: string; path?: string; pending: true; @@ -180,8 +183,10 @@ export type AttachmentDraftType = path: string; width?: number; height?: number; + clientUuid: string; } & BaseAttachmentDraftType) | { + clientUuid: string; contentType: MIME.MIMEType; fileName?: string; path?: string; diff --git a/ts/util/deleteForMe.ts b/ts/util/deleteForMe.ts index 27ba5a315f3..88539850d3b 100644 --- a/ts/util/deleteForMe.ts +++ b/ts/util/deleteForMe.ts @@ -13,7 +13,10 @@ import { 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, @@ -24,14 +27,15 @@ import type { ConversationToDelete, MessageToDelete, } from '../textsecure/messageReceiverEvents'; -import { isPniString } from '../types/ServiceId'; import type { AciString, PniString } from '../types/ServiceId'; -import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import type { AttachmentType } from '../types/Attachment'; +import type { MessageModel } from '../models/messages'; const { getMessagesBySentAt, getMostRecentAddressableMessages, removeMessagesInConversation, + saveMessage, } = dataInterface; export function doesMessageMatch({ @@ -93,14 +97,161 @@ export async function deleteMessage( const found = await findMatchingMessage(conversationId, query); if (!found) { - log.warn(`${logId}: Couldn't find matching message`); + log.warn(`${logId}/deleteMessage: Couldn't find matching message`); return false; } - await deleteAndCleanup([found], logId, { + 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, + logId, + }: { + deleteOnDisk: (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, + 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; + 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)(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; } diff --git a/ts/util/handleImageAttachment.ts b/ts/util/handleImageAttachment.ts index 96cc28bf62e..b3dc112a61c 100644 --- a/ts/util/handleImageAttachment.ts +++ b/ts/util/handleImageAttachment.ts @@ -55,6 +55,7 @@ export async function handleImageAttachment( return { blurHash, + clientUuid: genUuid(), contentType, data: new Uint8Array(data), fileName: fileName || file.name, diff --git a/ts/util/handleVideoAttachment.ts b/ts/util/handleVideoAttachment.ts index da792759bbe..b98daf982de 100644 --- a/ts/util/handleVideoAttachment.ts +++ b/ts/util/handleVideoAttachment.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { blobToArrayBuffer } from 'blob-util'; +import { v4 as generateUuid } from 'uuid'; import * as log from '../logging/log'; import { makeVideoScreenshot } from '../types/VisualAttachment'; @@ -21,6 +22,7 @@ export async function handleVideoAttachment( const data = await fileToBytes(file); const attachment: InMemoryAttachmentDraftType = { contentType: stringToMIMEType(file.type), + clientUuid: generateUuid(), data, fileName: file.name, path: file.name, diff --git a/ts/util/modifyTargetMessage.ts b/ts/util/modifyTargetMessage.ts index 3f276ce6e40..1db61e844fb 100644 --- a/ts/util/modifyTargetMessage.ts +++ b/ts/util/modifyTargetMessage.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { isEqual } from 'lodash'; +import PQueue from 'p-queue'; import type { ConversationModel } from '../models/conversations'; import type { MessageModel } from '../models/messages'; import type { SendStateByConversationId } from '../messages/MessageSendState'; @@ -29,7 +30,10 @@ import { getSourceServiceId } from '../messages/helpers'; import { missingCaseError } from './missingCaseError'; import { reduce } from './iterables'; import { strictAssert } from './assert'; -import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import { + applyDeleteAttachmentFromMessage, + applyDeleteMessage, +} from './deleteForMe'; export enum ModifyTargetMessageResult { Modified = 'Modified', @@ -55,14 +59,45 @@ export async function modifyTargetMessage( const syncDeletes = await DeletesForMe.forMessage(message.attributes); if (syncDeletes.length) { - if (!isFirstRun) { - await window.Signal.Data.removeMessage(message.id, { - fromSync: true, - singleProtoJobQueue, - }); + const attachmentDeletes = syncDeletes.filter( + item => item.deleteAttachmentData + ); + const isFullDelete = attachmentDeletes.length !== syncDeletes.length; + + if (isFullDelete) { + if (!isFirstRun) { + await applyDeleteMessage(message.attributes, logId); + } + + return ModifyTargetMessageResult.Deleted; } - return ModifyTargetMessageResult.Deleted; + log.warn( + `${logId}: Applying ${attachmentDeletes.length} attachment deletes in order` + ); + const deleteQueue = new PQueue({ concurrency: 1 }); + await deleteQueue.addAll( + attachmentDeletes.map(item => async () => { + if (!item.deleteAttachmentData) { + log.warn( + `${logId}: attachmentDeletes list had item with no deleteAttachmentData` + ); + return; + } + const result = await applyDeleteAttachmentFromMessage( + message, + item.deleteAttachmentData, + { + logId, + shouldSave: false, + deleteOnDisk: window.Signal.Migrations.deleteAttachmentData, + } + ); + if (result) { + changed = true; + } + }) + ); } if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) { diff --git a/ts/util/processAttachment.ts b/ts/util/processAttachment.ts index bf66874f323..c939169322b 100644 --- a/ts/util/processAttachment.ts +++ b/ts/util/processAttachment.ts @@ -1,6 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { v4 as generateUuid } from 'uuid'; + import * as log from '../logging/log'; import type { AttachmentType, @@ -35,6 +37,7 @@ export async function processAttachment( } else { const data = await fileToBytes(file); attachment = { + clientUuid: generateUuid(), contentType: fileType, data, fileName: file.name, @@ -50,6 +53,7 @@ export async function processAttachment( ); const data = await fileToBytes(file); attachment = { + clientUuid: generateUuid(), contentType: fileType, data, fileName: file.name, diff --git a/ts/util/resolveDraftAttachmentOnDisk.ts b/ts/util/resolveDraftAttachmentOnDisk.ts index 9cd37b28303..65b53887bd8 100644 --- a/ts/util/resolveDraftAttachmentOnDisk.ts +++ b/ts/util/resolveDraftAttachmentOnDisk.ts @@ -30,6 +30,7 @@ export function resolveDraftAttachmentOnDisk( ...pick(attachment, [ 'blurHash', 'caption', + 'clientUuid', 'contentType', 'fileName', 'flags', diff --git a/ts/util/syncTasks.ts b/ts/util/syncTasks.ts index 0a0b16f7290..2e1ae638b2e 100644 --- a/ts/util/syncTasks.ts +++ b/ts/util/syncTasks.ts @@ -11,6 +11,7 @@ import { deleteMessageSchema, deleteConversationSchema, deleteLocalConversationSchema, + deleteAttachmentSchema, } from '../textsecure/messageReceiverEvents'; import { receiptSyncTaskSchema, @@ -34,6 +35,7 @@ const syncTaskDataSchema = z.union([ deleteMessageSchema, deleteConversationSchema, deleteLocalConversationSchema, + deleteAttachmentSchema, receiptSyncTaskSchema, readSyncTaskSchema, viewSyncTaskSchema, @@ -54,6 +56,7 @@ const SCHEMAS_BY_TYPE: Record = { 'delete-message': deleteMessageSchema, 'delete-conversation': deleteConversationSchema, 'delete-local-conversation': deleteLocalConversationSchema, + 'delete-single-attachment': deleteAttachmentSchema, Delivery: receiptSyncTaskSchema, Read: receiptSyncTaskSchema, View: receiptSyncTaskSchema, @@ -153,6 +156,21 @@ export async function queueSyncTasks( log.info(`${logId}: Done; result=${result}`); }) ); + } else if (parsed.type === 'delete-single-attachment') { + drop( + DeletesForMe.onDelete({ + conversation: parsed.conversation, + deleteAttachmentData: { + clientUuid: parsed.clientUuid, + fallbackDigest: parsed.fallbackDigest, + fallbackPlaintextHash: parsed.fallbackPlaintextHash, + }, + envelopeId, + message: parsed.message, + syncTaskId: id, + timestamp: sentAt, + }) + ); } else if ( parsed.type === 'Delivery' || parsed.type === 'Read' || diff --git a/ts/util/uploadAttachment.ts b/ts/util/uploadAttachment.ts index 066774e233d..3ef5ae29b37 100644 --- a/ts/util/uploadAttachment.ts +++ b/ts/util/uploadAttachment.ts @@ -20,6 +20,7 @@ import { type HardcodedIVForEncryptionType, } from '../AttachmentCrypto'; import { missingCaseError } from './missingCaseError'; +import { uuidToBytes } from './uuidToBytes'; const CDNS_SUPPORTING_TUS = new Set([3]); @@ -37,9 +38,13 @@ export async function uploadAttachment( uploadType: 'standard', }); + const { blurHash, caption, clientUuid, fileName, flags, height, width } = + attachment; + return { cdnKey, cdnNumber, + clientUuid: clientUuid ? uuidToBytes(clientUuid) : undefined, key: keys, iv: encrypted.iv, size: attachment.data.byteLength, @@ -47,12 +52,12 @@ export async function uploadAttachment( plaintextHash: encrypted.plaintextHash, contentType: MIMETypeToString(attachment.contentType), - fileName: attachment.fileName, - flags: attachment.flags, - width: attachment.width, - height: attachment.height, - caption: attachment.caption, - blurHash: attachment.blurHash, + fileName, + flags, + width, + height, + caption, + blurHash, }; }