Backup support for sticker messages

This commit is contained in:
trevor-signal 2024-06-11 17:22:54 -04:00 committed by GitHub
parent 307e477035
commit 03406b15fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 381 additions and 39 deletions

View file

@ -37,6 +37,7 @@ import {
} from './mediaId';
import { redactGenericText } from '../../../util/privacy';
import { missingCaseError } from '../../../util/missingCaseError';
import { toLogFormat } from '../../../types/errors';
export function convertFilePointerToAttachment(
filePointer: Backups.FilePointer
@ -225,6 +226,16 @@ export async function getFilePointerForAttachment({
attachment.digest ?? ''
)})`;
if (attachment.size == null) {
log.warn(`${logId}: attachment had nullish size, dropping`);
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
invalidAttachmentLocator: getInvalidAttachmentLocator(),
}),
};
}
if (!isAttachmentLocallySaved(attachment)) {
// 1. If the attachment is undownloaded, we cannot trust its digest / mediaName. Thus,
// we only include a BackupLocator if this attachment already had one (e.g. we
@ -290,7 +301,7 @@ export async function getFilePointerForAttachment({
}
// Some attachments (e.g. those quoted ones copied from the original message) may not
// have any encryption info, including a digest!
// have any encryption info, including a digest.
if (attachment.digest) {
// From here on, this attachment is headed to (or already on) the backup tier!
const mediaNameForCurrentVersionOfAttachment =
@ -326,9 +337,25 @@ export async function getFilePointerForAttachment({
}
}
let attachmentWithNewEncryptionInfo: AttachmentReadyForBackup | undefined;
try {
log.info(`${logId}: Generating new encryption info for attachment`);
const attachmentWithNewEncryptionInfo =
attachmentWithNewEncryptionInfo =
await generateNewEncryptionInfoForAttachment(attachment);
} catch (e) {
log.error(
`${logId}: Error when generating new encryption info for attachment`,
toLogFormat(e)
);
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
invalidAttachmentLocator: getInvalidAttachmentLocator(),
}),
};
}
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,

View file

@ -59,7 +59,11 @@ import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message';
import { SignalService as Proto } from '../../protobuf';
import type { AttachmentType } from '../../types/Attachment';
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
import {
isVoiceMessage,
canBeDownloaded,
defaultBlurHash,
} from '../../types/Attachment';
import { type DefaultConversationColorType } from '../../types/Colors';
import { ReadStatus } from '../../messages/MessageReadStatus';
@ -301,16 +305,14 @@ export const getAttachmentsForMessage = ({
if (sticker && sticker.data) {
const { data } = sticker;
// We don't show anything if we don't have the sticker or the blurhash...
if (!data.blurHash && (data.pending || !data.path)) {
return [];
}
return [
{
...data,
// We want to show the blurhash for stickers, not the spinner
pending: false,
// Stickers are not guaranteed to have a blurhash (e.g. if imported but
// undownloaded from backup), so we want to make sure we have something to show
blurHash: data.blurHash ?? defaultBlurHash(),
url: data.path
? window.Signal.Migrations.getAbsoluteAttachmentPath(data.path)
: undefined,

View file

@ -4,6 +4,9 @@
import { v4 as generateGuid } from 'uuid';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { omit } from 'lodash';
import * as sinon from 'sinon';
import { join } from 'path';
import { assert } from 'chai';
import type { ConversationModel } from '../../models/conversations';
import * as Bytes from '../../Bytes';
@ -13,7 +16,13 @@ import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
import { AUDIO_MP3, IMAGE_JPEG, IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME';
import {
AUDIO_MP3,
IMAGE_JPEG,
IMAGE_PNG,
IMAGE_WEBP,
VIDEO_MP4,
} from '../../types/MIME';
import type {
MessageAttributesType,
QuotedMessageType,
@ -21,10 +30,12 @@ import type {
import { isVoiceMessage, type AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import { SignalService } from '../../protobuf';
import { getRandomBytes } from '../../Crypto';
const CONTACT_A = generateAci();
describe('backup/attachments', () => {
let sandbox: sinon.SinonSandbox;
let contactA: ConversationModel;
beforeEach(async () => {
@ -41,6 +52,25 @@ describe('backup/attachments', () => {
);
await loadCallsHistory();
sandbox = sinon.createSandbox();
const getAbsoluteAttachmentPath = sandbox.stub(
window.Signal.Migrations,
'getAbsoluteAttachmentPath'
);
getAbsoluteAttachmentPath.callsFake(path => {
if (path === 'path/to/sticker') {
return join(__dirname, '../../../fixtures/kitten-3-64-64.jpg');
}
if (path === 'path/to/thumbnail') {
return join(__dirname, '../../../fixtures/kitten-3-64-64.jpg');
}
return getAbsoluteAttachmentPath.wrappedMethod(path);
});
});
afterEach(() => {
sandbox.restore();
});
function getBase64(str: string): string {
@ -66,7 +96,7 @@ describe('backup/attachments', () => {
width: 150,
height: 150,
contentType: IMAGE_PNG,
path: '/path/to/thumbnail.png',
path: 'path/to/thumbnail',
},
...overrides,
};
@ -112,7 +142,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Messages
{ backupLevel: BackupLevel.Messages }
);
});
it('BackupLevel.Media, roundtrips normal attachments', async () => {
@ -142,7 +172,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Media
{ backupLevel: BackupLevel.Media }
);
});
it('roundtrips voice message attachments', async () => {
@ -174,7 +204,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Media
{ backupLevel: BackupLevel.Media }
);
});
});
@ -201,7 +231,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Messages
{ backupLevel: BackupLevel.Messages }
);
});
it('BackupLevel.Media, roundtrips preview attachments', async () => {
@ -245,7 +275,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Media
{ backupLevel: BackupLevel.Media }
);
});
});
@ -273,7 +303,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Messages
{ backupLevel: BackupLevel.Messages }
);
});
it('BackupLevel.Media, roundtrips contact attachments', async () => {
@ -308,7 +338,7 @@ describe('backup/attachments', () => {
],
}),
],
BackupLevel.Media
{ backupLevel: BackupLevel.Media }
);
});
});
@ -348,7 +378,7 @@ describe('backup/attachments', () => {
},
}),
],
BackupLevel.Messages
{ backupLevel: BackupLevel.Messages }
);
});
it('BackupLevel.Media, roundtrips quote attachments', async () => {
@ -393,7 +423,7 @@ describe('backup/attachments', () => {
},
}),
],
BackupLevel.Media
{ backupLevel: BackupLevel.Media }
);
});
@ -459,8 +489,241 @@ describe('backup/attachments', () => {
},
},
],
BackupLevel.Media
{ backupLevel: BackupLevel.Media }
);
});
it('handles quotes which have been copied over from the original (and lack all encryption info)', async () => {
const originalMessage = composeMessage(1);
const quotedMessage: QuotedMessageType = {
authorAci: originalMessage.sourceServiceId as AciString,
isViewOnce: false,
id: originalMessage.timestamp,
referencedMessageNotFound: false,
messageId: '',
isGiftBadge: false,
attachments: [
{
thumbnail: {
contentType: IMAGE_PNG,
size: 100,
path: 'path/to/thumbnail',
},
contentType: VIDEO_MP4,
},
],
};
const quoteMessage = composeMessage(originalMessage.timestamp + 1, {
quote: quotedMessage,
});
await asymmetricRoundtripHarness(
[originalMessage, quoteMessage],
[
originalMessage,
{
...quoteMessage,
quote: {
...quotedMessage,
referencedMessageNotFound: false,
attachments: [
{
// will do custom comparison for thumbnail below
contentType: VIDEO_MP4,
},
],
},
},
],
{
backupLevel: BackupLevel.Media,
comparator: (msgBefore, msgAfter) => {
if (msgBefore.timestamp === originalMessage.timestamp) {
return assert.deepStrictEqual(msgBefore, msgAfter);
}
const thumbnail = msgAfter.quote?.attachments[0]?.thumbnail;
strictAssert(thumbnail, 'quote thumbnail exists');
assert.deepStrictEqual(
omit(msgBefore, 'quote.attachments[0].thumbnail'),
omit(msgAfter, 'quote.attachments[0].thumbnail')
);
const { key, digest } = thumbnail;
strictAssert(digest, 'quote digest was created');
strictAssert(key, 'quote digest was created');
assert.deepStrictEqual(thumbnail, {
contentType: IMAGE_PNG,
size: 100,
key,
digest,
backupLocator: {
mediaName: digest,
},
});
},
}
);
});
});
describe('sticker attachments', () => {
const packId = Bytes.toHex(getRandomBytes(16));
const packKey = Bytes.toBase64(getRandomBytes(32));
describe('when copied over from sticker pack (i.e. missing encryption info)', () => {
it('BackupLevel.Media, generates new encryption info', async () => {
await asymmetricRoundtripHarness(
[
composeMessage(1, {
sticker: {
emoji: '🐒',
packId,
packKey,
stickerId: 0,
data: {
contentType: IMAGE_WEBP,
path: 'path/to/sticker',
size: 5322,
width: 512,
height: 512,
},
},
}),
],
[
composeMessage(1, {
sticker: {
emoji: '🐒',
packId,
packKey,
stickerId: 0,
data: {
contentType: IMAGE_WEBP,
size: 5322,
width: 512,
height: 512,
},
},
}),
],
{
backupLevel: BackupLevel.Media,
comparator: (msgBefore, msgAfter) => {
assert.deepStrictEqual(
omit(msgBefore, 'sticker.data'),
omit(msgAfter, 'sticker.data')
);
strictAssert(msgAfter.sticker?.data, 'sticker data exists');
const { key, digest } = msgAfter.sticker.data;
strictAssert(digest, 'sticker digest was created');
assert.equal(Bytes.fromBase64(digest ?? '').byteLength, 32);
assert.equal(Bytes.fromBase64(key ?? '').byteLength, 64);
assert.deepStrictEqual(msgAfter.sticker.data, {
contentType: IMAGE_WEBP,
size: 5322,
width: 512,
height: 512,
key,
digest,
backupLocator: {
mediaName: digest,
},
});
},
}
);
});
it('BackupLevel.Messages, generates invalid attachment locator', async () => {
// since we aren't re-uploading with new encryption info, we can't include this
// attachment in the backup proto
await asymmetricRoundtripHarness(
[
composeMessage(1, {
sticker: {
emoji: '🐒',
packId,
packKey,
stickerId: 0,
data: {
contentType: IMAGE_WEBP,
path: 'path/to/sticker',
size: 5322,
width: 512,
height: 512,
},
},
}),
],
[
composeMessage(1, {
sticker: {
emoji: '🐒',
packId,
packKey,
stickerId: 0,
data: {
contentType: IMAGE_WEBP,
size: 0,
error: true,
height: 512,
width: 512,
},
},
}),
],
{
backupLevel: BackupLevel.Messages,
}
);
});
});
describe('when this device sent sticker (i.e. encryption info exists on message)', () => {
it('roundtrips sticker', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
sticker: {
emoji: '🐒',
packId,
packKey,
stickerId: 0,
data: attachment,
},
}),
],
[
composeMessage(1, {
sticker: {
emoji: '🐒',
packId,
packKey,
stickerId: 0,
data: {
...omit(attachment, [
'iv',
'path',
'thumbnail',
'uploadTimestamp',
]),
backupLocator: { mediaName: attachment.digest },
},
},
}),
],
{
backupLevel: BackupLevel.Media,
}
);
});
});
});
});

View file

@ -16,6 +16,7 @@ import * as Bytes from '../../Bytes';
import type { AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId';
import { MASTER_KEY } from './helpers';
describe('convertFilePointerToAttachment', () => {
it('processes filepointer with attachmentLocator', () => {
@ -270,6 +271,22 @@ const notInBackupCdn: GetBackupCdnInfoType = async () => {
};
describe('getFilePointerForAttachment', () => {
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox.stub(window.storage, 'get').callsFake(key => {
if (key === 'masterKey') {
return MASTER_KEY;
}
return undefined;
});
});
afterEach(() => {
sandbox.restore();
});
describe('not downloaded locally', () => {
const undownloadedAttachment = composeAttachment({ path: undefined });
it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
@ -402,11 +419,7 @@ describe('getFilePointerForAttachment', () => {
describe('BackupLevel.Media', () => {
describe('if missing critical decryption / encryption info', () => {
const FILE_PATH = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox
.stub(window.Signal.Migrations, 'getAbsoluteAttachmentPath')
.callsFake(relPath => {
@ -417,10 +430,6 @@ describe('getFilePointerForAttachment', () => {
});
});
afterEach(() => {
sandbox.restore();
});
it('if missing key, generates new key & digest and removes existing CDN info', async () => {
const { filePointer: result } = await getFilePointerForAttachment({
attachment: {
@ -450,6 +459,18 @@ describe('getFilePointerForAttachment', () => {
);
});
it('if file does not exist at local path, returns invalid attachment locator', async () => {
await testAttachmentToFilePointer(
{
...downloadedAttachment,
path: 'no/file/here.png',
key: undefined,
},
filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Media }
);
});
it('if not on backup tier, and missing iv, regenerates encryption info', async () => {
const { filePointer: result } = await getFilePointerForAttachment({
attachment: {

View file

@ -10,7 +10,11 @@ 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 {
EditHistoryType,
MessageAttributesType,
MessageReactionType,
} from '../../model-types';
import type {
SendStateByConversationId,
SendState,
@ -31,18 +35,27 @@ export const PROFILE_KEY = getRandomBytes(32);
// This is preserved across data erasure
const CONVO_ID_TO_STABLE_ID = new Map<string, string>();
function mapConvoId(id?: string | null): string | undefined | null {
function mapConvoId(id?: string | null): string | undefined {
if (id == null) {
return id;
return undefined;
}
return CONVO_ID_TO_STABLE_ID.get(id) ?? id;
}
type MessageAttributesForComparisonType = Omit<
MessageAttributesType,
'id' | 'received_at' | 'editHistory' | 'reactions' | 'conversationId'
> & {
conversationId: string | undefined;
editHistory?: Array<Omit<EditHistoryType, 'received_at'>>;
reactions?: Array<Omit<MessageReactionType, 'fromId'>>;
};
// We need to eliminate fields that won't stay stable through import/export
function sortAndNormalize(
messages: Array<MessageAttributesType>
): Array<unknown> {
): Array<MessageAttributesForComparisonType> {
return sortBy(messages, 'sent_at').map(message => {
const {
changedId,
@ -113,11 +126,19 @@ function sortAndNormalize(
});
}
type HarnessOptionsType = {
backupLevel: BackupLevel;
comparator?: (
msgBefore: MessageAttributesForComparisonType,
msgAfter: MessageAttributesForComparisonType
) => void;
};
export async function symmetricRoundtripHarness(
messages: Array<MessageAttributesType>,
backupLevel: BackupLevel = BackupLevel.Messages
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
): Promise<void> {
return asymmetricRoundtripHarness(messages, messages, backupLevel);
return asymmetricRoundtripHarness(messages, messages, options);
}
async function updateConvoIdToTitle() {
@ -133,7 +154,7 @@ async function updateConvoIdToTitle() {
export async function asymmetricRoundtripHarness(
before: Array<MessageAttributesType>,
after: Array<MessageAttributesType>,
backupLevel: BackupLevel = BackupLevel.Messages
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
): Promise<void> {
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
@ -145,7 +166,7 @@ export async function asymmetricRoundtripHarness(
await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI });
await backupsService.exportToDisk(targetOutputFile, backupLevel);
await backupsService.exportToDisk(targetOutputFile, options.backupLevel);
await updateConvoIdToTitle();
@ -159,7 +180,15 @@ export async function asymmetricRoundtripHarness(
const expected = sortAndNormalize(after);
const actual = sortAndNormalize(messagesFromDatabase);
if (options.comparator) {
assert.strictEqual(actual.length, expected.length);
for (let i = 0; i < actual.length; i += 1) {
options.comparator(expected[i], actual[i]);
}
} else {
assert.deepEqual(actual, expected);
}
} finally {
fetchAndSaveBackupCdnObjectMetadata.restore();
await rm(outDir, { recursive: true });