Support for single-attachment delete synced across devices

This commit is contained in:
Scott Nonnenberg 2024-06-21 15:35:18 -07:00 committed by GitHub
parent 97229e2e65
commit ac04d02d4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 422 additions and 55 deletions

View file

@ -567,6 +567,9 @@ message MessageAttachment {
FilePointer pointer = 1; FilePointer pointer = 1;
Flag flag = 2; Flag flag = 2;
bool wasDownloaded = 3; 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 { message FilePointer {

View file

@ -655,6 +655,17 @@ message SyncMessage {
repeated AddressableMessage messages = 2; 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 { message ConversationDelete {
optional ConversationIdentifier conversation = 1; optional ConversationIdentifier conversation = 1;
repeated AddressableMessage mostRecentMessages = 2; repeated AddressableMessage mostRecentMessages = 2;
@ -668,6 +679,7 @@ message SyncMessage {
repeated MessageDeletes messageDeletes = 1; repeated MessageDeletes messageDeletes = 1;
repeated ConversationDelete conversationDeletes = 2; repeated ConversationDelete conversationDeletes = 2;
repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3;
repeated AttachmentDelete attachmentDeletes = 4;
} }
optional Sent sent = 1; optional Sent sent = 1;
@ -697,30 +709,37 @@ message SyncMessage {
message AttachmentPointer { message AttachmentPointer {
enum Flags { enum Flags {
VOICE_MESSAGE = 1; VOICE_MESSAGE = 1;
BORDERLESS = 2; BORDERLESS = 2;
// Our parser does not handle reserved in enums: DESKTOP-1569 // Our parser does not handle reserved in enums: DESKTOP-1569
// reserved 4; // reserved 4;
GIF = 8; GIF = 8;
} }
oneof attachment_identifier { oneof attachment_identifier {
fixed64 cdnId = 1; fixed64 cdnId = 1;
string cdnKey = 15; string cdnKey = 15;
} }
optional string contentType = 2; // Cross-client identifier for this attachment among all attachments on the
optional bytes key = 3; // owning message.
optional uint32 size = 4; optional bytes clientUuid = 20;
optional bytes thumbnail = 5; optional string contentType = 2;
optional bytes digest = 6; optional bytes key = 3;
optional string fileName = 7; optional uint32 size = 4;
optional uint32 flags = 8; optional bytes thumbnail = 5;
optional uint32 width = 9; optional bytes digest = 6;
optional uint32 height = 10; reserved /* incrementalMac with implicit chunk sizing */ 16;
optional string caption = 11; reserved /* incrementalMac for all attachment types */ 18;
optional string blurHash = 12; optional bytes incrementalMac = 19;
optional uint64 uploadTimestamp = 13; optional uint32 chunkSize = 17;
optional uint32 cdnNumber = 14; optional string fileName = 7;
// Next ID: 16 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 { message GroupContextV2 {

View file

@ -3,6 +3,7 @@
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as generateUuid } from 'uuid';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
@ -589,6 +590,7 @@ async function getMessageSendData({
maybeLongAttachment = { maybeLongAttachment = {
contentType: LONG_MESSAGE, contentType: LONG_MESSAGE,
clientUuid: generateUuid(),
fileName: `long-message-${targetTimestamp}.txt`, fileName: `long-message-${targetTimestamp}.txt`,
data, data,
size: data.byteLength, size: data.byteLength,

View file

@ -12,6 +12,7 @@ import type {
MessageToDelete, MessageToDelete,
} from '../textsecure/messageReceiverEvents'; } from '../textsecure/messageReceiverEvents';
import { import {
deleteAttachmentFromMessage,
deleteMessage, deleteMessage,
doesMessageMatch, doesMessageMatch,
getConversationFromTarget, getConversationFromTarget,
@ -23,6 +24,11 @@ const { removeSyncTaskById } = dataInterface;
export type DeleteForMeAttributesType = { export type DeleteForMeAttributesType = {
conversation: ConversationToDelete; conversation: ConversationToDelete;
deleteAttachmentData?: {
clientUuid?: string;
fallbackDigest?: string;
fallbackPlaintextHash?: string;
};
envelopeId: string; envelopeId: string;
message: MessageToDelete; message: MessageToDelete;
syncTaskId: string; syncTaskId: string;
@ -91,11 +97,23 @@ export async function onDelete(item: DeleteForMeAttributesType): Promise<void> {
conversation.queueJob('DeletesForMe.onDelete', async () => { conversation.queueJob('DeletesForMe.onDelete', async () => {
log.info(`${logId}: Starting...`); log.info(`${logId}: Starting...`);
const result = await deleteMessage( let result: boolean;
conversation.id, if (item.deleteAttachmentData) {
item.message, // This will find the message, then work with a backbone model to mirror what
logId // 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) { if (result) {
await remove(item); await remove(item);
} }

View file

@ -1833,6 +1833,7 @@ export class BackupExportStream extends Readable {
backupLevel: BackupLevel; backupLevel: BackupLevel;
messageReceivedAt: number; messageReceivedAt: number;
}): Promise<Backups.MessageAttachment> { }): Promise<Backups.MessageAttachment> {
const { clientUuid } = attachment;
const filePointer = await this.processAttachment({ const filePointer = await this.processAttachment({
attachment, attachment,
backupLevel, backupLevel,
@ -1843,6 +1844,7 @@ export class BackupExportStream extends Readable {
pointer: filePointer, pointer: filePointer,
flag: this.getMessageAttachmentFlag(attachment), flag: this.getMessageAttachmentFlag(attachment),
wasDownloaded: isDownloaded(attachment), // should always be true wasDownloaded: isDownloaded(attachment), // should always be true
clientUuid: clientUuid ? uuidToBytes(clientUuid) : undefined,
}); });
} }

View file

@ -38,6 +38,7 @@ import {
import { redactGenericText } from '../../../util/privacy'; import { redactGenericText } from '../../../util/privacy';
import { missingCaseError } from '../../../util/missingCaseError'; import { missingCaseError } from '../../../util/missingCaseError';
import { toLogFormat } from '../../../types/errors'; import { toLogFormat } from '../../../types/errors';
import { bytesToUuid } from '../../../util/uuidToBytes';
export function convertFilePointerToAttachment( export function convertFilePointerToAttachment(
filePointer: Backups.FilePointer filePointer: Backups.FilePointer
@ -128,10 +129,16 @@ export function convertFilePointerToAttachment(
export function convertBackupMessageAttachmentToAttachment( export function convertBackupMessageAttachmentToAttachment(
messageAttachment: Backups.IMessageAttachment messageAttachment: Backups.IMessageAttachment
): AttachmentType | null { ): AttachmentType | null {
const { clientUuid } = messageAttachment;
if (!messageAttachment.pointer) { if (!messageAttachment.pointer) {
return null; return null;
} }
const result = convertFilePointerToAttachment(messageAttachment.pointer); const result = {
...convertFilePointerToAttachment(messageAttachment.pointer),
clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined,
};
switch (messageAttachment.flag) { switch (messageAttachment.flag) {
case Backups.MessageAttachment.Flag.VOICE_MESSAGE: case Backups.MessageAttachment.Flag.VOICE_MESSAGE:
result.flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE; result.flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk'; import type { ThunkAction } from 'redux-thunk';
import { v4 as generateUuid } from 'uuid';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -172,6 +173,7 @@ export function completeRecording(
const voiceNoteAttachment: InMemoryAttachmentDraftType = { const voiceNoteAttachment: InMemoryAttachmentDraftType = {
pending: false, pending: false,
clientUuid: generateUuid(),
contentType: stringToMIMEType(blob.type), contentType: stringToMIMEType(blob.type),
data, data,
size: data.byteLength, size: data.byteLength,

View file

@ -799,6 +799,7 @@ function addAttachment(
// We do async operations first so multiple in-process addAttachments don't stomp on // We do async operations first so multiple in-process addAttachments don't stomp on
// each other. // each other.
const onDisk = await writeDraftAttachment(attachment); const onDisk = await writeDraftAttachment(attachment);
const toAdd = { ...onDisk, clientUuid: generateUuid() };
const state = getState(); const state = getState();
@ -822,7 +823,7 @@ function addAttachment(
// User has canceled the draft so we don't need to continue processing // User has canceled the draft so we don't need to continue processing
if (!hasDraftAttachmentPending) { if (!hasDraftAttachmentPending) {
await deleteDraftAttachment(onDisk); await deleteDraftAttachment(toAdd);
return; return;
} }
@ -835,9 +836,9 @@ function addAttachment(
log.warn( log.warn(
`addAttachment: Failed to find pending attachment with path ${attachment.path}` `addAttachment: Failed to find pending attachment with path ${attachment.path}`
); );
nextAttachments = [...draftAttachments, onDisk]; nextAttachments = [...draftAttachments, toAdd];
} else { } else {
nextAttachments = replaceIndex(draftAttachments, index, onDisk); nextAttachments = replaceIndex(draftAttachments, index, toAdd);
} }
replaceAttachments(conversationId, nextAttachments)( replaceAttachments(conversationId, nextAttachments)(
@ -1165,6 +1166,7 @@ function getPendingAttachment(file: File): AttachmentDraftType | undefined {
return { return {
contentType: fileType, contentType: fileType,
clientUuid: generateUuid(),
fileName, fileName,
size: file.size, size: file.size,
path: file.name, path: file.name,

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUuid } from 'uuid';
import type { import type {
AttachmentType, AttachmentType,
AttachmentDraftType, AttachmentDraftType,
@ -33,6 +35,7 @@ export const fakeDraftAttachment = (
overrides: Partial<AttachmentDraftType> = {} overrides: Partial<AttachmentDraftType> = {}
): AttachmentDraftType => ({ ): AttachmentDraftType => ({
pending: false, pending: false,
clientUuid: generateUuid(),
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
path: 'file.jpg', path: 'file.jpg',
size: 10304, size: 10304,

View file

@ -3,6 +3,7 @@
import { assert } from 'chai'; import { assert } from 'chai';
import Long from 'long'; import Long from 'long';
import { v4 as generateUuid } from 'uuid';
import { import {
processDataMessage, processDataMessage,
@ -12,14 +13,17 @@ import type { ProcessedAttachment } from '../textsecure/Types.d';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import { IMAGE_GIF, IMAGE_JPEG } from '../types/MIME'; import { IMAGE_GIF, IMAGE_JPEG } from '../types/MIME';
import { generateAci } from '../types/ServiceId'; import { generateAci } from '../types/ServiceId';
import { uuidToBytes } from '../util/uuidToBytes';
const ACI_1 = generateAci(); const ACI_1 = generateAci();
const FLAGS = Proto.DataMessage.Flags; const FLAGS = Proto.DataMessage.Flags;
const TIMESTAMP = Date.now(); const TIMESTAMP = Date.now();
const CLIENT_UUID = generateUuid();
const UNPROCESSED_ATTACHMENT: Proto.IAttachmentPointer = { const UNPROCESSED_ATTACHMENT: Proto.IAttachmentPointer = {
cdnId: Long.fromNumber(123), cdnId: Long.fromNumber(123),
clientUuid: uuidToBytes(CLIENT_UUID),
key: new Uint8Array([1, 2, 3]), key: new Uint8Array([1, 2, 3]),
digest: new Uint8Array([4, 5, 6]), digest: new Uint8Array([4, 5, 6]),
contentType: IMAGE_GIF, contentType: IMAGE_GIF,
@ -28,6 +32,7 @@ const UNPROCESSED_ATTACHMENT: Proto.IAttachmentPointer = {
const PROCESSED_ATTACHMENT: ProcessedAttachment = { const PROCESSED_ATTACHMENT: ProcessedAttachment = {
cdnId: '123', cdnId: '123',
clientUuid: CLIENT_UUID,
key: 'AQID', key: 'AQID',
digest: 'BAUG', digest: 'BAUG',
contentType: IMAGE_GIF, contentType: IMAGE_GIF,

View file

@ -4,6 +4,7 @@
import { assert } from 'chai'; import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { v4 as generateUuid } from 'uuid';
import type { ReduxActions } from '../../../state/types'; import type { ReduxActions } from '../../../state/types';
import { import {
@ -68,6 +69,7 @@ describe('both/state/ducks/composer', () => {
const attachments: Array<AttachmentDraftType> = [ const attachments: Array<AttachmentDraftType> = [
{ {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
clientUuid: generateUuid(),
pending: true, pending: true,
size: 2433, size: 2433,
path: 'image.jpg', path: 'image.jpg',

View file

@ -84,6 +84,7 @@ describe('backup/attachments', () => {
return { return {
cdnKey: `cdnKey${index}`, cdnKey: `cdnKey${index}`,
cdnNumber: 3, cdnNumber: 3,
clientUuid: generateGuid(),
key: getBase64(`key${index}`), key: getBase64(`key${index}`),
digest: getBase64(`digest${index}`), digest: getBase64(`digest${index}`),
iv: getBase64(`iv${index}`), iv: getBase64(`iv${index}`),
@ -212,7 +213,7 @@ describe('backup/attachments', () => {
describe('Preview attachments', () => { describe('Preview attachments', () => {
it('BackupLevel.Messages, roundtrips preview attachments', async () => { it('BackupLevel.Messages, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness( await asymmetricRoundtripHarness(
[ [
@ -236,7 +237,7 @@ describe('backup/attachments', () => {
); );
}); });
it('BackupLevel.Media, roundtrips preview attachments', async () => { it('BackupLevel.Media, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness( await asymmetricRoundtripHarness(
@ -283,7 +284,7 @@ describe('backup/attachments', () => {
describe('contact attachments', () => { describe('contact attachments', () => {
it('BackupLevel.Messages, roundtrips contact attachments', async () => { it('BackupLevel.Messages, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness( await asymmetricRoundtripHarness(
[ [
@ -308,7 +309,7 @@ describe('backup/attachments', () => {
); );
}); });
it('BackupLevel.Media, roundtrips contact attachments', async () => { it('BackupLevel.Media, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness( await asymmetricRoundtripHarness(
@ -346,7 +347,7 @@ describe('backup/attachments', () => {
describe('quotes', () => { describe('quotes', () => {
it('BackupLevel.Messages, roundtrips quote attachments', async () => { it('BackupLevel.Messages, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
const authorAci = generateAci(); const authorAci = generateAci();
const quotedMessage: QuotedMessageType = { const quotedMessage: QuotedMessageType = {
authorAci, authorAci,
@ -383,7 +384,7 @@ describe('backup/attachments', () => {
); );
}); });
it('BackupLevel.Media, roundtrips quote attachments', async () => { it('BackupLevel.Media, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
const authorAci = generateAci(); const authorAci = generateAci();
const quotedMessage: QuotedMessageType = { const quotedMessage: QuotedMessageType = {
@ -435,7 +436,7 @@ describe('backup/attachments', () => {
attachments: [existingAttachment], attachments: [existingAttachment],
}); });
const quoteAttachment = composeAttachment(2); const quoteAttachment = composeAttachment(2, { clientUuid: undefined });
delete quoteAttachment.thumbnail; delete quoteAttachment.thumbnail;
strictAssert(quoteAttachment.digest, 'digest exists'); 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)', () => { describe('when this device sent sticker (i.e. encryption info exists on message)', () => {
it('roundtrips sticker', async () => { it('roundtrips sticker', async () => {
const attachment = composeAttachment(1); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness( await asymmetricRoundtripHarness(
[ [

View file

@ -156,6 +156,7 @@ import { getCallEventForProto } from '../util/callDisposition';
import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey'; import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey';
import { CallLogEvent } from '../types/CallDisposition'; import { CallLogEvent } from '../types/CallDisposition';
import { CallLinkUpdateSyncType } from '../types/CallLink'; import { CallLinkUpdateSyncType } from '../types/CallLink';
import { bytesToUuid } from '../util/uuidToBytes';
const GROUPV2_ID_LENGTH = 32; const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000; const RETRY_TIMEOUT = 2 * 60 * 1000;
@ -3761,6 +3762,67 @@ export default class MessageReceiver
eventData = eventData.concat(localOnlyConversationDeletes); eventData = eventData.concat(localOnlyConversationDeletes);
} }
if (deleteSync.attachmentDeletes?.length) {
const attachmentDeletes: Array<DeleteForMeSyncTarget> =
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) { if (!eventData.length) {
throw new Error(`${logId}: Nothing found in sync message!`); throw new Error(`${logId}: Nothing found in sync message!`);
} }

View file

@ -1534,6 +1534,10 @@ export default class MessageSender {
deleteForMe.localOnlyConversationDeletes.push({ deleteForMe.localOnlyConversationDeletes.push({
conversation, conversation,
}); });
} else if (item.type === 'delete-single-attachment') {
throw new Error(
"getDeleteForMeSyncMessage: Desktop currently does not support sending 'delete-single-attachment' messages"
);
} else { } else {
throw missingCaseError(item); throw missingCaseError(item);
} }

View file

@ -105,8 +105,9 @@ export type ProcessedEnvelope = Readonly<{
export type ProcessedAttachment = { export type ProcessedAttachment = {
cdnId?: string; cdnId?: string;
cdnKey?: string; cdnKey?: string;
digest?: string;
contentType: MIMEType; contentType: MIMEType;
clientUuid?: string;
digest?: string;
key?: string; key?: string;
size: number; size: number;
fileName?: string; fileName?: string;

View file

@ -526,10 +526,20 @@ export const deleteLocalConversationSchema = z.object({
conversation: conversationToDeleteSchema, conversation: conversationToDeleteSchema,
timestamp: z.number(), 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([ export const deleteForMeSyncTargetSchema = z.union([
deleteMessageSchema, deleteMessageSchema,
deleteConversationSchema, deleteConversationSchema,
deleteLocalConversationSchema, deleteLocalConversationSchema,
deleteAttachmentSchema,
]); ]);
export type DeleteForMeSyncTarget = z.infer<typeof deleteForMeSyncTargetSchema>; export type DeleteForMeSyncTarget = z.infer<typeof deleteForMeSyncTargetSchema>;

View file

@ -31,6 +31,7 @@ import { PaymentEventKind } from '../types/Payment';
import { filterAndClean } from '../types/BodyRange'; import { filterAndClean } from '../types/BodyRange';
import { isAciString } from '../util/isAciString'; import { isAciString } from '../util/isAciString';
import { normalizeAci } from '../util/normalizeAci'; import { normalizeAci } from '../util/normalizeAci';
import { bytesToUuid } from '../util/uuidToBytes';
const FLAGS = Proto.DataMessage.Flags; const FLAGS = Proto.DataMessage.Flags;
export const ATTACHMENT_MAX = 32; export const ATTACHMENT_MAX = 32;
@ -52,7 +53,7 @@ export function processAttachment(
const { cdnId } = attachment; const { cdnId } = attachment;
const hasCdnId = Long.isLong(cdnId) ? !cdnId.isZero() : Boolean(cdnId); 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)) { if (!isNumber(size)) {
throw new Error('Missing size on incoming attachment!'); throw new Error('Missing size on incoming attachment!');
} }
@ -61,6 +62,7 @@ export function processAttachment(
...shallowDropNull(attachment), ...shallowDropNull(attachment),
cdnId: hasCdnId ? String(cdnId) : undefined, cdnId: hasCdnId ? String(cdnId) : undefined,
clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined,
contentType: contentType contentType: contentType
? stringToMIMEType(contentType) ? stringToMIMEType(contentType)
: APPLICATION_OCTET_STREAM, : APPLICATION_OCTET_STREAM,

View file

@ -44,6 +44,7 @@ export type AttachmentType = {
error?: boolean; error?: boolean;
blurHash?: string; blurHash?: string;
caption?: string; caption?: string;
clientUuid?: string;
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
digest?: string; digest?: string;
fileName?: string; fileName?: string;
@ -154,6 +155,7 @@ export type BaseAttachmentDraftType = {
export type InMemoryAttachmentDraftType = export type InMemoryAttachmentDraftType =
| ({ | ({
data: Uint8Array; data: Uint8Array;
clientUuid: string;
pending: false; pending: false;
screenshotData?: Uint8Array; screenshotData?: Uint8Array;
fileName?: string; fileName?: string;
@ -161,6 +163,7 @@ export type InMemoryAttachmentDraftType =
} & BaseAttachmentDraftType) } & BaseAttachmentDraftType)
| { | {
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
clientUuid: string;
fileName?: string; fileName?: string;
path?: string; path?: string;
pending: true; pending: true;
@ -180,8 +183,10 @@ export type AttachmentDraftType =
path: string; path: string;
width?: number; width?: number;
height?: number; height?: number;
clientUuid: string;
} & BaseAttachmentDraftType) } & BaseAttachmentDraftType)
| { | {
clientUuid: string;
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
fileName?: string; fileName?: string;
path?: string; path?: string;

View file

@ -13,7 +13,10 @@ import {
import { missingCaseError } from './missingCaseError'; import { missingCaseError } from './missingCaseError';
import { getMessageSentTimestampSet } from './getMessageSentTimestampSet'; import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
import { getAuthor } from '../messages/helpers'; import { getAuthor } from '../messages/helpers';
import { isPniString } from '../types/ServiceId';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import dataInterface, { deleteAndCleanup } from '../sql/Client'; import dataInterface, { deleteAndCleanup } from '../sql/Client';
import { deleteData } from '../types/Attachment';
import type { import type {
ConversationAttributesType, ConversationAttributesType,
@ -24,14 +27,15 @@ import type {
ConversationToDelete, ConversationToDelete,
MessageToDelete, MessageToDelete,
} from '../textsecure/messageReceiverEvents'; } from '../textsecure/messageReceiverEvents';
import { isPniString } from '../types/ServiceId';
import type { AciString, PniString } 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 { const {
getMessagesBySentAt, getMessagesBySentAt,
getMostRecentAddressableMessages, getMostRecentAddressableMessages,
removeMessagesInConversation, removeMessagesInConversation,
saveMessage,
} = dataInterface; } = dataInterface;
export function doesMessageMatch({ export function doesMessageMatch({
@ -93,14 +97,161 @@ export async function deleteMessage(
const found = await findMatchingMessage(conversationId, query); const found = await findMatchingMessage(conversationId, query);
if (!found) { if (!found) {
log.warn(`${logId}: Couldn't find matching message`); log.warn(`${logId}/deleteMessage: Couldn't find matching message`);
return false; 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<void> {
await deleteAndCleanup([message], logId, {
fromSync: true, fromSync: true,
singleProtoJobQueue, 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; return true;
} }

View file

@ -55,6 +55,7 @@ export async function handleImageAttachment(
return { return {
blurHash, blurHash,
clientUuid: genUuid(),
contentType, contentType,
data: new Uint8Array(data), data: new Uint8Array(data),
fileName: fileName || file.name, fileName: fileName || file.name,

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { blobToArrayBuffer } from 'blob-util'; import { blobToArrayBuffer } from 'blob-util';
import { v4 as generateUuid } from 'uuid';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { makeVideoScreenshot } from '../types/VisualAttachment'; import { makeVideoScreenshot } from '../types/VisualAttachment';
@ -21,6 +22,7 @@ export async function handleVideoAttachment(
const data = await fileToBytes(file); const data = await fileToBytes(file);
const attachment: InMemoryAttachmentDraftType = { const attachment: InMemoryAttachmentDraftType = {
contentType: stringToMIMEType(file.type), contentType: stringToMIMEType(file.type),
clientUuid: generateUuid(),
data, data,
fileName: file.name, fileName: file.name,
path: file.name, path: file.name,

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import PQueue from 'p-queue';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import type { SendStateByConversationId } from '../messages/MessageSendState'; import type { SendStateByConversationId } from '../messages/MessageSendState';
@ -29,7 +30,10 @@ import { getSourceServiceId } from '../messages/helpers';
import { missingCaseError } from './missingCaseError'; import { missingCaseError } from './missingCaseError';
import { reduce } from './iterables'; import { reduce } from './iterables';
import { strictAssert } from './assert'; import { strictAssert } from './assert';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import {
applyDeleteAttachmentFromMessage,
applyDeleteMessage,
} from './deleteForMe';
export enum ModifyTargetMessageResult { export enum ModifyTargetMessageResult {
Modified = 'Modified', Modified = 'Modified',
@ -55,14 +59,45 @@ export async function modifyTargetMessage(
const syncDeletes = await DeletesForMe.forMessage(message.attributes); const syncDeletes = await DeletesForMe.forMessage(message.attributes);
if (syncDeletes.length) { if (syncDeletes.length) {
if (!isFirstRun) { const attachmentDeletes = syncDeletes.filter(
await window.Signal.Data.removeMessage(message.id, { item => item.deleteAttachmentData
fromSync: true, );
singleProtoJobQueue, 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)) { if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) {

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUuid } from 'uuid';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { import type {
AttachmentType, AttachmentType,
@ -35,6 +37,7 @@ export async function processAttachment(
} else { } else {
const data = await fileToBytes(file); const data = await fileToBytes(file);
attachment = { attachment = {
clientUuid: generateUuid(),
contentType: fileType, contentType: fileType,
data, data,
fileName: file.name, fileName: file.name,
@ -50,6 +53,7 @@ export async function processAttachment(
); );
const data = await fileToBytes(file); const data = await fileToBytes(file);
attachment = { attachment = {
clientUuid: generateUuid(),
contentType: fileType, contentType: fileType,
data, data,
fileName: file.name, fileName: file.name,

View file

@ -30,6 +30,7 @@ export function resolveDraftAttachmentOnDisk(
...pick(attachment, [ ...pick(attachment, [
'blurHash', 'blurHash',
'caption', 'caption',
'clientUuid',
'contentType', 'contentType',
'fileName', 'fileName',
'flags', 'flags',

View file

@ -11,6 +11,7 @@ import {
deleteMessageSchema, deleteMessageSchema,
deleteConversationSchema, deleteConversationSchema,
deleteLocalConversationSchema, deleteLocalConversationSchema,
deleteAttachmentSchema,
} from '../textsecure/messageReceiverEvents'; } from '../textsecure/messageReceiverEvents';
import { import {
receiptSyncTaskSchema, receiptSyncTaskSchema,
@ -34,6 +35,7 @@ const syncTaskDataSchema = z.union([
deleteMessageSchema, deleteMessageSchema,
deleteConversationSchema, deleteConversationSchema,
deleteLocalConversationSchema, deleteLocalConversationSchema,
deleteAttachmentSchema,
receiptSyncTaskSchema, receiptSyncTaskSchema,
readSyncTaskSchema, readSyncTaskSchema,
viewSyncTaskSchema, viewSyncTaskSchema,
@ -54,6 +56,7 @@ const SCHEMAS_BY_TYPE: Record<SyncTaskData['type'], ZodSchema> = {
'delete-message': deleteMessageSchema, 'delete-message': deleteMessageSchema,
'delete-conversation': deleteConversationSchema, 'delete-conversation': deleteConversationSchema,
'delete-local-conversation': deleteLocalConversationSchema, 'delete-local-conversation': deleteLocalConversationSchema,
'delete-single-attachment': deleteAttachmentSchema,
Delivery: receiptSyncTaskSchema, Delivery: receiptSyncTaskSchema,
Read: receiptSyncTaskSchema, Read: receiptSyncTaskSchema,
View: receiptSyncTaskSchema, View: receiptSyncTaskSchema,
@ -153,6 +156,21 @@ export async function queueSyncTasks(
log.info(`${logId}: Done; result=${result}`); 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 ( } else if (
parsed.type === 'Delivery' || parsed.type === 'Delivery' ||
parsed.type === 'Read' || parsed.type === 'Read' ||

View file

@ -20,6 +20,7 @@ import {
type HardcodedIVForEncryptionType, type HardcodedIVForEncryptionType,
} from '../AttachmentCrypto'; } from '../AttachmentCrypto';
import { missingCaseError } from './missingCaseError'; import { missingCaseError } from './missingCaseError';
import { uuidToBytes } from './uuidToBytes';
const CDNS_SUPPORTING_TUS = new Set([3]); const CDNS_SUPPORTING_TUS = new Set([3]);
@ -37,9 +38,13 @@ export async function uploadAttachment(
uploadType: 'standard', uploadType: 'standard',
}); });
const { blurHash, caption, clientUuid, fileName, flags, height, width } =
attachment;
return { return {
cdnKey, cdnKey,
cdnNumber, cdnNumber,
clientUuid: clientUuid ? uuidToBytes(clientUuid) : undefined,
key: keys, key: keys,
iv: encrypted.iv, iv: encrypted.iv,
size: attachment.data.byteLength, size: attachment.data.byteLength,
@ -47,12 +52,12 @@ export async function uploadAttachment(
plaintextHash: encrypted.plaintextHash, plaintextHash: encrypted.plaintextHash,
contentType: MIMETypeToString(attachment.contentType), contentType: MIMETypeToString(attachment.contentType),
fileName: attachment.fileName, fileName,
flags: attachment.flags, flags,
width: attachment.width, width,
height: attachment.height, height,
caption: attachment.caption, caption,
blurHash: attachment.blurHash, blurHash,
}; };
} }