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;
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 {

View file

@ -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 {

View file

@ -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,

View file

@ -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);
}

View file

@ -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,
});
}

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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(
[

View file

@ -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!`);
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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>;

View file

@ -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,

View file

@ -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;

View file

@ -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;
}

View file

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

View file

@ -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,

View file

@ -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)) {

View file

@ -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,

View file

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

View file

@ -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' ||

View file

@ -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,
};
}