Backup support for link preview and contact attachments
This commit is contained in:
parent
4aa898f495
commit
534029d2e6
7 changed files with 357 additions and 54 deletions
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
253
ts/test-electron/backup/attachments_test.ts
Normal file
253
ts/test-electron/backup/attachments_test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,7 +9,7 @@ type GenericLinkPreviewType<Image> = {
|
|||
domain?: string;
|
||||
url: string;
|
||||
isStickerPack?: boolean;
|
||||
isCallLink: boolean;
|
||||
isCallLink?: boolean;
|
||||
image?: Readonly<Image>;
|
||||
date?: number;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue