641 lines
18 KiB
TypeScript
641 lines
18 KiB
TypeScript
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { assert } from 'chai';
|
|
import { v4 as generateGuid } from 'uuid';
|
|
|
|
import * as Bytes from '../Bytes';
|
|
import type {
|
|
EphemeralAttachmentFields,
|
|
ScreenshotType,
|
|
AttachmentType,
|
|
ThumbnailType,
|
|
BackupThumbnailType,
|
|
} from '../types/Attachment';
|
|
import {
|
|
APPLICATION_OCTET_STREAM,
|
|
IMAGE_JPEG,
|
|
IMAGE_PNG,
|
|
LONG_MESSAGE,
|
|
type MIMEType,
|
|
} from '../types/MIME';
|
|
import type { MessageAttributesType } from '../model-types';
|
|
import { generateAci } from '../types/ServiceId';
|
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
|
import { SeenStatus } from '../MessageSeenStatus';
|
|
import { DataWriter, DataReader } from '../sql/Client';
|
|
import { strictAssert } from '../util/assert';
|
|
import { HOUR, MINUTE } from '../util/durations';
|
|
|
|
const CONTACT_A = generateAci();
|
|
const contactAConversationId = generateGuid();
|
|
function getBase64(str: string): string {
|
|
return Bytes.toBase64(Bytes.fromString(str));
|
|
}
|
|
|
|
function composeThumbnail(
|
|
index: number,
|
|
overrides?: Partial<AttachmentType>
|
|
): ThumbnailType {
|
|
return {
|
|
size: 1024,
|
|
contentType: IMAGE_PNG,
|
|
path: `path/to/thumbnail${index}`,
|
|
localKey: `thumbnailLocalKey${index}`,
|
|
version: 2,
|
|
...overrides,
|
|
};
|
|
}
|
|
function composeBackupThumbnail(
|
|
index: number,
|
|
overrides?: Partial<AttachmentType>
|
|
): BackupThumbnailType {
|
|
return {
|
|
size: 1024,
|
|
contentType: IMAGE_JPEG,
|
|
path: `path/to/backupThumbnail${index}`,
|
|
localKey: 'backupThumbnailLocalKey',
|
|
version: 2,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function composeScreenshot(
|
|
index: number,
|
|
overrides?: Partial<AttachmentType>
|
|
): ScreenshotType {
|
|
return {
|
|
size: 1024,
|
|
contentType: IMAGE_PNG,
|
|
path: `path/to/screenshot${index}`,
|
|
localKey: `screenshotLocalKey${index}`,
|
|
version: 2,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
let index = 0;
|
|
function composeAttachment(
|
|
key?: string,
|
|
overrides?: Partial<AttachmentType>
|
|
// NB: Required<AttachmentType> to ensure we are roundtripping every property in
|
|
// AttachmentType! If you are here you probably just added a field to AttachmentType;
|
|
// Make sure you add a column to the `message_attachments` table and update
|
|
// MESSAGE_ATTACHMENT_COLUMNS.
|
|
): Required<Omit<AttachmentType, keyof EphemeralAttachmentFields>> {
|
|
const label = `${key ?? 'attachment'}${index}`;
|
|
const attachment = {
|
|
cdnKey: `cdnKey${label}`,
|
|
cdnNumber: 3,
|
|
key: getBase64(`key${label}`),
|
|
digest: getBase64(`digest${label}`),
|
|
size: 100,
|
|
downloadPath: 'downloadPath',
|
|
contentType: IMAGE_JPEG,
|
|
path: `path/to/file${label}`,
|
|
pending: false,
|
|
localKey: 'localKey',
|
|
plaintextHash: `plaintextHash${label}`,
|
|
uploadTimestamp: index,
|
|
clientUuid: generateGuid(),
|
|
width: 100,
|
|
height: 120,
|
|
blurHash: 'blurHash',
|
|
caption: 'caption',
|
|
fileName: 'filename',
|
|
flags: 8,
|
|
incrementalMac: 'incrementalMac',
|
|
chunkSize: 128,
|
|
version: 2,
|
|
backupCdnNumber: index,
|
|
localBackupPath: `localBackupPath/${label}`,
|
|
// This would only exist on a story message with contentType TEXT_ATTACHMENT,
|
|
// but inluding it here to ensure we are roundtripping all fields
|
|
textAttachment: {
|
|
text: 'text',
|
|
textStyle: 3,
|
|
},
|
|
// defaulting all of these booleans to true to ensure that we are actually
|
|
// roundtripping them to/from the DB
|
|
wasTooBig: true,
|
|
error: true,
|
|
isCorrupted: true,
|
|
backfillError: true,
|
|
copied: true,
|
|
thumbnail: composeThumbnail(index),
|
|
screenshot: composeScreenshot(index),
|
|
thumbnailFromBackup: composeBackupThumbnail(index),
|
|
...overrides,
|
|
} as const;
|
|
|
|
index += 1;
|
|
return attachment;
|
|
}
|
|
|
|
function composeMessage(
|
|
timestamp: number,
|
|
overrides?: Partial<MessageAttributesType>
|
|
): MessageAttributesType {
|
|
return {
|
|
schemaVersion: 12,
|
|
conversationId: contactAConversationId,
|
|
id: generateGuid(),
|
|
type: 'incoming',
|
|
body: undefined,
|
|
received_at: timestamp,
|
|
received_at_ms: timestamp,
|
|
sourceServiceId: CONTACT_A,
|
|
sourceDevice: 1,
|
|
sent_at: timestamp,
|
|
timestamp,
|
|
readStatus: ReadStatus.Read,
|
|
seenStatus: SeenStatus.Seen,
|
|
isErased: false,
|
|
mentionsMe: false,
|
|
isViewOnce: false,
|
|
unidentifiedDeliveryReceived: false,
|
|
serverGuid: undefined,
|
|
serverTimestamp: undefined,
|
|
source: undefined,
|
|
storyId: undefined,
|
|
expirationStartTimestamp: undefined,
|
|
expireTimer: undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('normalizes attachment references', () => {
|
|
beforeEach(async () => {
|
|
await DataWriter.removeAll();
|
|
});
|
|
|
|
it('saves message with undownloaded attachments', async () => {
|
|
const attachment1: AttachmentType = {
|
|
...composeAttachment(),
|
|
path: undefined,
|
|
localKey: undefined,
|
|
plaintextHash: undefined,
|
|
version: undefined,
|
|
};
|
|
const attachment2: AttachmentType = {
|
|
...composeAttachment(),
|
|
path: undefined,
|
|
localKey: undefined,
|
|
plaintextHash: undefined,
|
|
version: undefined,
|
|
};
|
|
|
|
delete attachment1.thumbnail;
|
|
delete attachment1.screenshot;
|
|
delete attachment1.thumbnailFromBackup;
|
|
|
|
delete attachment2.thumbnail;
|
|
delete attachment2.screenshot;
|
|
delete attachment2.thumbnailFromBackup;
|
|
|
|
const attachments = [attachment1, attachment2];
|
|
const message = composeMessage(Date.now(), {
|
|
attachments,
|
|
});
|
|
|
|
await DataWriter.saveMessage(message, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const references = await DataReader.getAttachmentReferencesForMessages([
|
|
message.id,
|
|
]);
|
|
|
|
assert.equal(references.length, attachments.length);
|
|
|
|
const messageFromDB = await DataReader.getMessageById(message.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB, message);
|
|
});
|
|
|
|
it('saves message with downloaded attachments, and hydrates on get', async () => {
|
|
const attachments = [
|
|
composeAttachment('first'),
|
|
composeAttachment('second'),
|
|
];
|
|
const message = composeMessage(Date.now(), {
|
|
attachments,
|
|
});
|
|
|
|
await DataWriter.saveMessage(message, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const messageFromDB = await DataReader.getMessageById(message.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB, message);
|
|
});
|
|
|
|
it('saves and re-hydrates messages with normal, body, preview, quote, contact, and sticker attachments', async () => {
|
|
const attachment1 = composeAttachment('first');
|
|
const attachment2 = composeAttachment('second');
|
|
const previewAttachment1 = composeAttachment('preview1');
|
|
const previewAttachment2 = composeAttachment('preview2');
|
|
const quoteAttachment1 = composeAttachment('quote1');
|
|
const quoteAttachment2 = composeAttachment('quote2');
|
|
const contactAttachment1 = composeAttachment('contact1');
|
|
const contactAttachment2 = composeAttachment('contact2');
|
|
const stickerAttachment = composeAttachment('sticker');
|
|
const bodyAttachment = composeAttachment('body', {
|
|
contentType: LONG_MESSAGE,
|
|
});
|
|
|
|
const message = composeMessage(Date.now(), {
|
|
attachments: [attachment1, attachment2],
|
|
bodyAttachment,
|
|
preview: [
|
|
{
|
|
title: 'preview',
|
|
description: 'description',
|
|
domain: 'domain',
|
|
url: 'https://signal.org',
|
|
isStickerPack: false,
|
|
isCallLink: false,
|
|
image: previewAttachment1,
|
|
date: Date.now(),
|
|
},
|
|
{
|
|
title: 'preview2',
|
|
description: 'description2',
|
|
domain: 'domain2',
|
|
url: 'https://signal2.org',
|
|
isStickerPack: true,
|
|
isCallLink: false,
|
|
image: previewAttachment2,
|
|
date: Date.now(),
|
|
},
|
|
],
|
|
quote: {
|
|
id: Date.now(),
|
|
referencedMessageNotFound: true,
|
|
isViewOnce: false,
|
|
messageId: 'quotedMessageId',
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: quoteAttachment1,
|
|
},
|
|
{
|
|
contentType: IMAGE_PNG,
|
|
thumbnail: quoteAttachment2,
|
|
},
|
|
],
|
|
},
|
|
contact: [
|
|
{
|
|
name: {
|
|
givenName: 'Alice',
|
|
familyName: 'User',
|
|
},
|
|
avatar: {
|
|
isProfile: true,
|
|
avatar: contactAttachment1,
|
|
},
|
|
},
|
|
{
|
|
name: {
|
|
givenName: 'Bob',
|
|
familyName: 'User',
|
|
},
|
|
avatar: {
|
|
isProfile: false,
|
|
avatar: contactAttachment2,
|
|
},
|
|
},
|
|
],
|
|
sticker: {
|
|
packId: 'stickerPackId',
|
|
stickerId: 123,
|
|
packKey: 'abcdefg',
|
|
data: stickerAttachment,
|
|
},
|
|
});
|
|
|
|
await DataWriter.saveMessage(message, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const messageFromDB = await DataReader.getMessageById(message.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB, message);
|
|
});
|
|
|
|
it('handles quote attachments with copied thumbnail', async () => {
|
|
const referencedAttachment = composeAttachment('quotedattachment', {
|
|
thumbnail: composeThumbnail(0),
|
|
});
|
|
strictAssert(referencedAttachment.plaintextHash, 'exists');
|
|
const referencedMessage = composeMessage(1, {
|
|
attachments: [referencedAttachment],
|
|
});
|
|
const quoteMessage = composeMessage(2, {
|
|
quote: {
|
|
id: Date.now(),
|
|
referencedMessageNotFound: false,
|
|
isViewOnce: false,
|
|
messageId: 'quotedMessageId',
|
|
attachments: [
|
|
{
|
|
fileName: 'filename',
|
|
contentType: IMAGE_PNG,
|
|
thumbnail: { ...composeAttachment(), copied: true },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await DataWriter.saveMessage(referencedMessage, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
await DataWriter.saveMessage(quoteMessage, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const messageFromDB = await DataReader.getMessageById(quoteMessage.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB, quoteMessage);
|
|
});
|
|
|
|
it('deletes and re-orders attachments as necessary', async () => {
|
|
await DataWriter.removeAll();
|
|
const attachment1 = composeAttachment();
|
|
const attachment2 = composeAttachment();
|
|
const attachment3 = composeAttachment();
|
|
|
|
const attachments = [attachment1, attachment2, attachment3];
|
|
const message = composeMessage(Date.now(), {
|
|
attachments,
|
|
});
|
|
|
|
await DataWriter.saveMessage(message, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const messageFromDB = await DataReader.getMessageById(message.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB, message);
|
|
|
|
/** Re-order the attachments */
|
|
const messageWithReorderedAttachments = {
|
|
...message,
|
|
attachments: [attachment3, attachment2, attachment1],
|
|
};
|
|
await DataWriter.saveMessage(messageWithReorderedAttachments, {
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
const messageWithReorderedAttachmentsFromDB =
|
|
await DataReader.getMessageById(message.id);
|
|
|
|
assert(messageWithReorderedAttachmentsFromDB, 'message was saved');
|
|
assert.deepEqual(
|
|
messageWithReorderedAttachmentsFromDB,
|
|
messageWithReorderedAttachments
|
|
);
|
|
|
|
/** Drop the last attachment */
|
|
const messageWithDeletedAttachment = {
|
|
...message,
|
|
attachments: [attachment1, attachment2],
|
|
};
|
|
await DataWriter.saveMessage(messageWithDeletedAttachment, {
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
const messageWithDeletedAttachmentFromDB = await DataReader.getMessageById(
|
|
message.id
|
|
);
|
|
|
|
assert(messageWithDeletedAttachmentFromDB, 'message was saved');
|
|
assert.deepEqual(
|
|
messageWithDeletedAttachmentFromDB,
|
|
messageWithDeletedAttachment
|
|
);
|
|
});
|
|
|
|
it('deletes attachment references when message is deleted', async () => {
|
|
const attachment1 = composeAttachment();
|
|
const attachment2 = composeAttachment();
|
|
|
|
const attachments = [attachment1, attachment2];
|
|
const message = composeMessage(Date.now(), {
|
|
attachments,
|
|
});
|
|
|
|
const message2 = composeMessage(Date.now(), {
|
|
attachments: [composeAttachment()],
|
|
});
|
|
|
|
await DataWriter.saveMessages([message, message2], {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
assert.equal(
|
|
(await DataReader.getAttachmentReferencesForMessages([message.id]))
|
|
.length,
|
|
2
|
|
);
|
|
assert.equal(
|
|
(await DataReader.getAttachmentReferencesForMessages([message2.id]))
|
|
.length,
|
|
1
|
|
);
|
|
|
|
// Deleting message should delete all references
|
|
await DataWriter._removeMessage(message.id);
|
|
|
|
assert.deepEqual(
|
|
await DataReader.getAttachmentReferencesForMessages([message.id]),
|
|
[]
|
|
);
|
|
assert.equal(
|
|
(await DataReader.getAttachmentReferencesForMessages([message2.id]))
|
|
.length,
|
|
1
|
|
);
|
|
});
|
|
it('roundtrips edithistory attachments with normal, body, preview, and quote attachments', async () => {
|
|
const mainMessageFields = {
|
|
attachments: [composeAttachment('main1'), composeAttachment('main2')],
|
|
bodyAttachment: composeAttachment('body1', {
|
|
contentType: LONG_MESSAGE,
|
|
}),
|
|
preview: [
|
|
{
|
|
title: 'preview',
|
|
description: 'description',
|
|
domain: 'domain',
|
|
url: 'https://signal.org',
|
|
isStickerPack: false,
|
|
isCallLink: false,
|
|
image: composeAttachment('preview1'),
|
|
date: Date.now(),
|
|
},
|
|
],
|
|
quote: {
|
|
id: Date.now(),
|
|
referencedMessageNotFound: true,
|
|
isViewOnce: false,
|
|
messageId: 'quotedMessageId',
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: composeAttachment('quote3'),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const now = Date.now();
|
|
|
|
const message = composeMessage(now, {
|
|
...mainMessageFields,
|
|
editMessageReceivedAt: now + HOUR + 42,
|
|
editMessageTimestamp: now + HOUR,
|
|
editHistory: [
|
|
{
|
|
timestamp: now + HOUR,
|
|
received_at: now + HOUR + 42,
|
|
attachments: [
|
|
composeAttachment('main.edit1.1'),
|
|
composeAttachment('main.edit1.2'),
|
|
],
|
|
bodyAttachment: composeAttachment('body.edit1', {
|
|
contentType: LONG_MESSAGE,
|
|
}),
|
|
preview: [
|
|
{
|
|
title: 'preview',
|
|
description: 'description',
|
|
domain: 'domain',
|
|
url: 'https://signal.org',
|
|
isStickerPack: false,
|
|
isCallLink: true,
|
|
image: composeAttachment('preview.edit1'),
|
|
date: Date.now(),
|
|
},
|
|
],
|
|
quote: {
|
|
id: Date.now(),
|
|
referencedMessageNotFound: true,
|
|
isViewOnce: false,
|
|
messageId: 'quotedMessageId',
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: composeAttachment('quote.edit1'),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
timestamp: now + MINUTE,
|
|
received_at: now + MINUTE + 42,
|
|
attachments: [
|
|
composeAttachment('main.edit2.1'),
|
|
composeAttachment('main.edit2.2'),
|
|
],
|
|
bodyAttachment: composeAttachment('body.edit2', {
|
|
contentType: LONG_MESSAGE,
|
|
}),
|
|
preview: [
|
|
{
|
|
title: 'preview',
|
|
description: 'description',
|
|
domain: 'domain',
|
|
url: 'https://signal.org',
|
|
isStickerPack: false,
|
|
isCallLink: true,
|
|
image: composeAttachment('preview.edit2'),
|
|
date: Date.now(),
|
|
},
|
|
],
|
|
quote: {
|
|
id: Date.now(),
|
|
referencedMessageNotFound: true,
|
|
isViewOnce: false,
|
|
messageId: 'quotedMessageId',
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: composeAttachment('quote.edit2'),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
timestamp: now,
|
|
received_at: now,
|
|
...mainMessageFields,
|
|
},
|
|
],
|
|
});
|
|
|
|
await DataWriter.saveMessage(message, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const messageAttachments =
|
|
await DataReader.getAttachmentReferencesForMessages([message.id]);
|
|
// 5 attachments, plus 3 versions in editHistory = 20 attachments total
|
|
assert.deepEqual(messageAttachments.length, 20);
|
|
|
|
const messageFromDB = await DataReader.getMessageById(message.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB, message);
|
|
});
|
|
it('handles bad data', async () => {
|
|
const attachment: AttachmentType = {
|
|
...composeAttachment(),
|
|
size: undefined as unknown as number,
|
|
contentType: undefined as unknown as MIMEType,
|
|
uploadTimestamp: {
|
|
low: 6174,
|
|
high: 0,
|
|
unsigned: false,
|
|
} as unknown as number,
|
|
incrementalMac: Bytes.fromString('incrementalMac') as unknown as string,
|
|
};
|
|
const message = composeMessage(Date.now(), {
|
|
attachments: [attachment],
|
|
});
|
|
|
|
await DataWriter.saveMessage(message, {
|
|
forceSave: true,
|
|
ourAci: generateAci(),
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
const messageFromDB = await DataReader.getMessageById(message.id);
|
|
assert(messageFromDB, 'message was saved');
|
|
assert.deepEqual(messageFromDB.attachments?.[0], {
|
|
...attachment,
|
|
size: 0,
|
|
contentType: APPLICATION_OCTET_STREAM,
|
|
uploadTimestamp: undefined,
|
|
incrementalMac: undefined,
|
|
});
|
|
});
|
|
});
|