Support for single-attachment delete synced across devices
This commit is contained in:
parent
97229e2e65
commit
ac04d02d4f
26 changed files with 422 additions and 55 deletions
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<void> {
|
|||
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);
|
||||
}
|
||||
|
|
|
@ -1833,6 +1833,7 @@ export class BackupExportStream extends Readable {
|
|||
backupLevel: BackupLevel;
|
||||
messageReceivedAt: number;
|
||||
}): Promise<Backups.MessageAttachment> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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> = {}
|
||||
): AttachmentDraftType => ({
|
||||
pending: false,
|
||||
clientUuid: generateUuid(),
|
||||
contentType: IMAGE_JPEG,
|
||||
path: 'file.jpg',
|
||||
size: 10304,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<AttachmentDraftType> = [
|
||||
{
|
||||
contentType: IMAGE_JPEG,
|
||||
clientUuid: generateUuid(),
|
||||
pending: true,
|
||||
size: 2433,
|
||||
path: 'image.jpg',
|
||||
|
|
|
@ -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(
|
||||
[
|
||||
|
|
|
@ -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<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) {
|
||||
throw new Error(`${logId}: Nothing found in sync message!`);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
3
ts/textsecure/Types.d.ts
vendored
3
ts/textsecure/Types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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<typeof deleteForMeSyncTargetSchema>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ export async function handleImageAttachment(
|
|||
|
||||
return {
|
||||
blurHash,
|
||||
clientUuid: genUuid(),
|
||||
contentType,
|
||||
data: new Uint8Array(data),
|
||||
fileName: fileName || file.name,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -30,6 +30,7 @@ export function resolveDraftAttachmentOnDisk(
|
|||
...pick(attachment, [
|
||||
'blurHash',
|
||||
'caption',
|
||||
'clientUuid',
|
||||
'contentType',
|
||||
'fileName',
|
||||
'flags',
|
||||
|
|
|
@ -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<SyncTaskData['type'], ZodSchema> = {
|
|||
'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' ||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue