Backup support for link preview and contact attachments

This commit is contained in:
trevor-signal 2024-05-30 14:53:30 -04:00 committed by GitHub
parent 4aa898f495
commit 534029d2e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 357 additions and 54 deletions

View file

@ -479,7 +479,7 @@ message ContactAttachment {
repeated Phone number = 3;
repeated Email email = 4;
repeated PostalAddress address = 5;
optional string avatarUrlPath = 6;
optional FilePointer avatar = 6;
optional string organization = 7;
}

View file

@ -99,6 +99,7 @@ import {
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
import { getBackupCdnInfo } from './util/mediaId';
import { ReadStatus } from '../../messages/MessageReadStatus';
const MAX_CONCURRENCY = 10;
@ -760,23 +761,30 @@ export class BackupExportStream extends Readable {
} else if (contact && contact[0]) {
const contactMessage = new Backups.ContactMessage();
// TODO (DESKTOP-6845): properly handle avatarUrlPath
contactMessage.contact = contact.map(contactDetails => ({
...contactDetails,
number: contactDetails.number?.map(number => ({
...number,
type: numberToPhoneType(number.type),
})),
email: contactDetails.email?.map(email => ({
...email,
type: numberToPhoneType(email.type),
})),
address: contactDetails.address?.map(address => ({
...address,
type: numberToAddressType(address.type),
})),
}));
contactMessage.contact = await Promise.all(
contact.map(async contactDetails => ({
...contactDetails,
number: contactDetails.number?.map(number => ({
...number,
type: numberToPhoneType(number.type),
})),
email: contactDetails.email?.map(email => ({
...email,
type: numberToPhoneType(email.type),
})),
address: contactDetails.address?.map(address => ({
...address,
type: numberToAddressType(address.type),
})),
avatar: contactDetails.avatar?.avatar
? await this.processAttachment({
attachment: contactDetails.avatar.avatar,
backupLevel,
messageReceivedAt: message.received_at,
})
: undefined,
}))
);
const reactions = this.getMessageReactions(message);
if (reactions != null) {
@ -790,7 +798,13 @@ export class BackupExportStream extends Readable {
stickerProto.packId = Bytes.fromHex(sticker.packId);
stickerProto.packKey = Bytes.fromBase64(sticker.packKey);
stickerProto.stickerId = sticker.stickerId;
// TODO (DESKTOP-6845): properly handle data FilePointer
stickerProto.data = sticker.data
? await this.processAttachment({
attachment: sticker.data,
backupLevel,
messageReceivedAt: message.received_at,
})
: undefined;
result.stickerMessage = {
sticker: stickerProto,
@ -817,14 +831,25 @@ export class BackupExportStream extends Readable {
bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)),
},
linkPreview: message.preview?.map(preview => {
return {
url: preview.url,
title: preview.title,
description: preview.description,
date: getSafeLongFromTimestamp(preview.date),
};
}),
linkPreview: message.preview
? await Promise.all(
message.preview.map(async preview => {
return {
url: preview.url,
title: preview.title,
description: preview.description,
date: getSafeLongFromTimestamp(preview.date),
image: preview.image
? await this.processAttachment({
attachment: preview.image,
backupLevel,
messageReceivedAt: message.received_at,
})
: undefined,
};
})
)
: undefined,
reactions: this.getMessageReactions(message),
};
}
@ -1785,7 +1810,7 @@ export class BackupExportStream extends Readable {
private getIncomingMessageDetails({
received_at_ms: receivedAtMs,
serverTimestamp,
readAt,
readStatus,
}: MessageAttributesType): Backups.ChatItem.IIncomingMessageDetails {
return {
dateReceived:
@ -1794,7 +1819,7 @@ export class BackupExportStream extends Readable {
serverTimestamp != null
? getSafeLongFromTimestamp(serverTimestamp)
: null,
read: Boolean(readAt),
read: readStatus === ReadStatus.Read,
};
}

View file

@ -873,15 +873,32 @@ export class BackupImportStream extends Writable {
data: Backups.IStandardMessage
): Partial<MessageAttributesType> {
return {
body: data.text?.body ?? '',
attachments: data.attachments
?.map(attachment => {
if (!attachment.pointer) {
return null;
}
return convertFilePointerToAttachment(attachment.pointer);
})
.filter(isNotNil),
body: data.text?.body || undefined,
attachments: data.attachments?.length
? data.attachments
.map(attachment => {
if (!attachment.pointer) {
return null;
}
return convertFilePointerToAttachment(attachment.pointer);
})
.filter(isNotNil)
: undefined,
preview: data.linkPreview?.length
? data.linkPreview.map(preview => {
const { url } = preview;
strictAssert(url, 'preview must have a URL');
return {
url,
title: dropNull(preview.title),
description: dropNull(preview.description),
date: getTimestampFromLong(preview.date),
image: preview.image
? convertFilePointerToAttachment(preview.image)
: undefined,
};
})
: undefined,
reactions: this.fromReactions(data.reactions),
};
}
@ -889,6 +906,9 @@ export class BackupImportStream extends Writable {
private fromReactions(
reactions: ReadonlyArray<Backups.IReaction> | null | undefined
): Array<MessageReactionType> | undefined {
if (!reactions?.length) {
return undefined;
}
return reactions?.map(
({ emoji, authorId, sentTimestamp, receivedTimestamp }) => {
strictAssert(emoji != null, 'reaction must have an emoji');
@ -938,14 +958,8 @@ export class BackupImportStream extends Writable {
return {
message: {
contact: (chatItem.contactMessage.contact ?? []).map(details => {
const {
name,
number,
email,
address,
// TODO (DESKTOP-6845): properly handle avatarUrlPath
organization,
} = details;
const { avatar, name, number, email, address, organization } =
details;
return {
name: name
@ -1016,6 +1030,12 @@ export class BackupImportStream extends Writable {
})
: undefined,
organization: dropNull(organization),
avatar: avatar
? {
avatar: convertFilePointerToAttachment(avatar),
isProfile: false,
}
: undefined,
};
}),
reactions: this.fromReactions(chatItem.contactMessage.reactions),
@ -1038,7 +1058,7 @@ export class BackupImportStream extends Writable {
);
const {
stickerMessage: {
sticker: { emoji, packId, packKey, stickerId },
sticker: { emoji, packId, packKey, stickerId, data },
},
} = chatItem;
strictAssert(emoji != null, 'stickerMessage must have an emoji');
@ -1059,6 +1079,7 @@ export class BackupImportStream extends Writable {
packId: Bytes.toHex(packId),
packKey: Bytes.toBase64(packKey),
stickerId,
data: data ? convertFilePointerToAttachment(data) : undefined,
},
reactions: this.fromReactions(chatItem.stickerMessage.reactions),
},

View file

@ -0,0 +1,253 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateGuid } from 'uuid';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { omit } from 'lodash';
import type { ConversationModel } from '../../models/conversations';
import * as Bytes from '../../Bytes';
import Data from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
import { IMAGE_JPEG } from '../../types/MIME';
import type { MessageAttributesType } from '../../model-types';
import type { AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
const CONTACT_A = generateAci();
describe('backup/attachments', () => {
let contactA: ConversationModel;
beforeEach(async () => {
await Data._removeAllMessages();
await Data._removeAllConversations();
window.storage.reset();
await setupBasics();
contactA = await window.ConversationController.getOrCreateAndWait(
CONTACT_A,
'private',
{ systemGivenName: 'CONTACT_A' }
);
await loadCallsHistory();
});
function getBase64(str: string): string {
return Bytes.toBase64(Bytes.fromString(str));
}
function composeAttachment(
index: number,
overrides?: Partial<AttachmentType>
): AttachmentType {
return {
cdnKey: `cdnKey${index}`,
cdnNumber: 3,
key: getBase64(`key${index}`),
digest: getBase64(`digest${index}`),
iv: getBase64(`iv${index}`),
size: 100,
contentType: IMAGE_JPEG,
path: `/path/to/file${index}.png`,
uploadTimestamp: index,
...overrides,
};
}
function composeMessage(
timestamp: number,
overrides?: Partial<MessageAttributesType>
): MessageAttributesType {
return {
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: timestamp,
received_at_ms: timestamp,
sourceServiceId: CONTACT_A,
sourceDevice: timestamp,
sent_at: timestamp,
timestamp,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
...overrides,
};
}
describe('normal attachments', () => {
it('BackupLevel.Messages, roundtrips normal attachments', async () => {
const attachment1 = composeAttachment(1);
const attachment2 = composeAttachment(2);
await asymmetricRoundtripHarness(
[
composeMessage(1, {
attachments: [attachment1, attachment2],
}),
],
// path & iv will not be roundtripped
[
composeMessage(1, {
attachments: [
omit(attachment1, ['path', 'iv']),
omit(attachment2, ['path', 'iv']),
],
}),
],
BackupLevel.Messages
);
});
it('BackupLevel.Media, roundtrips normal attachments', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
attachments: [attachment],
}),
],
[
composeMessage(1, {
// path, iv, and uploadTimestamp will not be roundtripped,
// but there will be a backupLocator
attachments: [
{
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
backupLocator: { mediaName: attachment.digest },
},
],
}),
],
BackupLevel.Media
);
});
});
describe('Preview attachments', () => {
it('BackupLevel.Messages, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1);
await asymmetricRoundtripHarness(
[
composeMessage(1, {
preview: [{ url: 'url', date: 1, image: attachment }],
}),
],
// path & iv will not be roundtripped
[
composeMessage(1, {
preview: [
{ url: 'url', date: 1, image: omit(attachment, ['path', 'iv']) },
],
}),
],
BackupLevel.Messages
);
});
it('BackupLevel.Media, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
preview: [
{
url: 'url',
date: 1,
title: 'title',
description: 'description',
image: attachment,
},
],
}),
],
[
composeMessage(1, {
preview: [
{
url: 'url',
date: 1,
title: 'title',
description: 'description',
image: {
// path, iv, and uploadTimestamp will not be roundtripped,
// but there will be a backupLocator
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
backupLocator: { mediaName: attachment.digest },
},
},
],
}),
],
BackupLevel.Media
);
});
});
describe('contact attachments', () => {
it('BackupLevel.Messages, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1);
await asymmetricRoundtripHarness(
[
composeMessage(1, {
contact: [{ avatar: { avatar: attachment, isProfile: false } }],
}),
],
// path & iv will not be roundtripped
[
composeMessage(1, {
contact: [
{
avatar: {
avatar: omit(attachment, ['path', 'iv']),
isProfile: false,
},
},
],
}),
],
BackupLevel.Messages
);
});
it('BackupLevel.Media, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
contact: [{ avatar: { avatar: attachment, isProfile: false } }],
}),
],
// path, iv, and uploadTimestamp will not be roundtripped,
// but there will be a backupLocator
[
composeMessage(1, {
contact: [
{
avatar: {
avatar: {
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
backupLocator: { mediaName: attachment.digest },
},
isProfile: false,
},
},
],
}),
],
BackupLevel.Media
);
});
});
});

View file

@ -8,6 +8,7 @@ import { sortBy } from 'lodash';
import { createReadStream } from 'fs';
import { mkdtemp, rm } from 'fs/promises';
import * as sinon from 'sinon';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import type { MessageAttributesType } from '../../model-types';
import type {
@ -109,9 +110,10 @@ function sortAndNormalize(
}
export async function symmetricRoundtripHarness(
messages: Array<MessageAttributesType>
messages: Array<MessageAttributesType>,
backupLevel: BackupLevel = BackupLevel.Messages
): Promise<void> {
return asymmetricRoundtripHarness(messages, messages);
return asymmetricRoundtripHarness(messages, messages, backupLevel);
}
async function updateConvoIdToTitle() {
@ -126,7 +128,8 @@ async function updateConvoIdToTitle() {
export async function asymmetricRoundtripHarness(
before: Array<MessageAttributesType>,
after: Array<MessageAttributesType>
after: Array<MessageAttributesType>,
backupLevel: BackupLevel = BackupLevel.Messages
): Promise<void> {
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
@ -138,7 +141,7 @@ export async function asymmetricRoundtripHarness(
await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI });
await backupsService.exportToDisk(targetOutputFile);
await backupsService.exportToDisk(targetOutputFile, backupLevel);
await updateConvoIdToTitle();

View file

@ -152,6 +152,9 @@ describe('backups', function (this: Mocha.Suite) {
);
}
const backupPath = bootstrap.getBackupPath('backup.bin');
await app.exportBackupToDisk(backupPath);
const comparator = await bootstrap.createScreenshotComparator(
app,
async (window, snapshot) => {
@ -185,8 +188,6 @@ describe('backups', function (this: Mocha.Suite) {
this.test
);
const backupPath = bootstrap.getBackupPath('backup.bin');
await app.exportBackupToDisk(backupPath);
await app.close();
// Restart

View file

@ -9,7 +9,7 @@ type GenericLinkPreviewType<Image> = {
domain?: string;
url: string;
isStickerPack?: boolean;
isCallLink: boolean;
isCallLink?: boolean;
image?: Readonly<Image>;
date?: number;
};